[web] Create DSFormControl Input components (#29647)

* Create DS version for Bootstrap Input form elements

* Move DS Button Storybook to DS component folder

* Use phosphor icons

* Add ds-focus-outline mixin

* Use math.div

GitOrigin-RevId: e50934212ec5949f0f7abc7880eb73933fce2a9b
This commit is contained in:
Antoine Clausse
2025-11-17 12:50:08 +01:00
committed by Copybot
parent 1447842fbd
commit 0e4682ef89
11 changed files with 277 additions and 4 deletions

View File

@@ -0,0 +1,22 @@
import { forwardRef } from 'react'
import { Form, FormControlProps } from 'react-bootstrap'
import classnames from 'classnames'
interface ButtonProps extends FormControlProps {
size?: 'lg'
}
const DSFormControl = forwardRef<HTMLInputElement, ButtonProps>(
({ className, ...props }, ref) => {
return (
<Form.Control
ref={ref}
{...props}
className={classnames('form-control-ds', className)}
/>
)
}
)
DSFormControl.displayName = 'DSFormControl'
export default DSFormControl

View File

@@ -0,0 +1,20 @@
import { Form } from 'react-bootstrap'
import FormText from '@/shared/components/form/form-text'
import { ComponentProps } from 'react'
export type FormFeedbackProps = Pick<
ComponentProps<typeof Form.Control.Feedback>,
'type' | 'className' | 'children'
>
function DSFormFeedback(props: FormFeedbackProps) {
return (
<Form.Control.Feedback {...props}>
<FormText type={props.type === 'invalid' ? 'error' : 'success'}>
{props.children}
</FormText>
</Form.Control.Feedback>
)
}
export default DSFormFeedback

View File

@@ -0,0 +1,18 @@
import { forwardRef } from 'react'
import { Form, FormGroupProps } from 'react-bootstrap'
import classnames from 'classnames'
const DSFormGroup = forwardRef<typeof Form.Group, FormGroupProps>(
({ className, ...props }, ref) => {
return (
<Form.Group
className={classnames('form-group-ds', className)}
{...props}
ref={ref}
/>
)
}
)
DSFormGroup.displayName = 'DSFormGroup'
export default DSFormGroup

View File

@@ -0,0 +1,14 @@
import { ComponentProps } from 'react'
import { Form } from 'react-bootstrap'
import classnames from 'classnames'
function DSFormLabel({
className,
...props
}: ComponentProps<typeof Form.Label>) {
return (
<Form.Label {...props} className={classnames('form-label-ds', className)} />
)
}
export default DSFormLabel

View File

@@ -0,0 +1,48 @@
import { Form, FormTextProps as BS5FormTextProps } from 'react-bootstrap'
import classnames from 'classnames'
import { MergeAndOverride } from '@ol-types/utils'
import { CheckCircle, WarningCircle } from '@phosphor-icons/react'
type TextType = 'success' | 'error'
export type FormTextProps = MergeAndOverride<
BS5FormTextProps,
{ type?: TextType }
>
const typeClasses = {
error: 'text-danger',
success: 'text-success',
} as const
export const getFormTextClass = (type?: TextType) =>
type && type in typeClasses
? typeClasses[type as keyof typeof typeClasses]
: undefined
function FormTextIcon({ type }: { type?: TextType }) {
switch (type) {
case 'success':
return <CheckCircle className="ciam-form-text-icon" />
case 'error':
return <WarningCircle className="ciam-form-text-icon" />
default:
return null
}
}
function DSFormText({ type, children, className, ...rest }: FormTextProps) {
return (
<Form.Text
className={classnames('form-text-ds', className, getFormTextClass(type))}
{...rest}
>
<span className="form-text-inner-ds">
<FormTextIcon type={type} />
{children}
</span>
</Form.Text>
)
}
export default DSFormText

View File

@@ -9,7 +9,7 @@ export const Button = (args: Args) => {
}
const meta: Meta<typeof DSButton> = {
title: 'Shared / Components / DS Button',
title: 'Shared / DS Components',
component: DSButton,
args: {
children: 'Button',

View File

@@ -0,0 +1,58 @@
import { Meta } from '@storybook/react'
import { figmaDesignUrl } from '../../../.storybook/utils/figma-design-url'
import DSFormControl from '@/shared/components/ds/ds-form-control'
import DSFormText from '@/shared/components/ds/ds-form-text'
import DSFormGroup from '@/shared/components/ds/ds-form-group'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import { ComponentProps } from 'react'
type Args = ComponentProps<typeof DSFormControl> & {
textType: ComponentProps<typeof DSFormText>['type']
}
export const FormControl = ({ textType, value, ...args }: Args) => {
return (
<DSFormGroup>
<DSFormLabel htmlFor="form-control-id">Form input label</DSFormLabel>
<DSFormControl
id="form-control-id"
name="form-control-name"
{...args}
value={value || undefined}
/>
<DSFormText type={textType}>Form input feedback</DSFormText>
</DSFormGroup>
)
}
const meta: Meta<typeof FormControl> = {
title: 'Shared / DS Components',
component: FormControl,
argTypes: {
disabled: { control: 'boolean' },
isInvalid: { control: 'boolean' },
placeholder: { control: 'text' },
readOnly: { control: 'boolean' },
value: { control: 'text' },
size: { control: 'radio', options: ['lg', undefined] },
textType: { control: 'radio', options: ['error', 'success', undefined] },
},
parameters: {
controls: {
include: [
'disabled',
'isInvalid',
'placeholder',
'readOnly',
'size',
'value',
'textType',
],
},
...figmaDesignUrl(
'https://www.figma.com/design/aJQlecvqCS9Ry8b6JA1lQN/DS---Components?node-id=6318-428&t=pcx9KKzhlzpRmA4S-0'
),
},
}
export default meta

View File

@@ -1 +1,2 @@
@import 'button';
@import 'form-control';

View File

@@ -1,11 +1,11 @@
@import '../mixins';
.btn.btn-ds {
--bs-btn-font-family: var(--ds-font-family-sans);
--bs-btn-border-radius: var(--ds-border-radius-200);
&:focus-visible {
outline: 2px solid var(--ds-color-yellow-500);
outline-offset: 2px;
box-shadow: none;
@include ds-focus-outline;
}
// Default size

View File

@@ -0,0 +1,85 @@
@use 'sass:math';
@import '../mixins';
.form-control-ds,
.form-group-ds,
.form-text-ds,
.form-label-ds {
--bs-body-font-family: var(--ds-font-family-sans), sans-serif;
--bs-success-rgb: 25, 117, 76; // #19754c
--bs-danger-rgb: 195, 9, 43; // #c3092b
--content-placeholder: var(--ds-color-text-disabled);
// Without this, it inherits the body's --bs-body-font-family which isn't the DS font
font-family: var(--ds-font-family-sans), sans-serif;
}
input.form-control.form-control-ds {
border-radius: var(--ds-border-radius-200);
border-color: var(--ds-color-neutral-300);
color: var(--ds-color-neutral-950);
padding: calc(var(--ds-spacing-250) - 1px) calc(var(--ds-spacing-300) - 1px);
&.form-control-lg {
padding: calc(var(--ds-spacing-350) - 1px) calc(var(--ds-spacing-300) - 1px);
}
&:read-only {
background: var(--ds-color-neutral-200);
color: var(--ds-color-text-secondary);
}
&:disabled {
color: var(--ds-color-text-disabled);
background: var(--ds-color-neutral-100);
}
&:not(:disabled, :focus-visible) {
&:hover,
&.hover {
border-color: var(--ds-color-neutral-400);
}
}
&:focus-visible,
&:focus {
@include ds-focus-outline;
border-color: var(--ds-color-neutral-950);
}
&.is-invalid,
.was-validated &:invalid {
border-color: var(--ds-color-red-500);
}
}
.form-label.form-label-ds {
@include ds-body-md-semibold;
color: var(--ds-color-text-primary);
&:has(+ .form-control:read-only) {
color: var(--ds-color-text-secondary);
}
&:has(+ .form-control:disabled) {
color: var(--ds-color-text-disabled);
}
}
.form-text.form-text-ds {
color: var(--ds-color-text-secondary);
.form-text-inner-ds {
display: flex;
gap: var(--ds-spacing-200);
margin-top: var(--ds-spacing-150);
@include ds-body-sm-regular;
}
.ciam-form-text-icon {
font-size: math.div(20em, 14);
}
}

View File

@@ -107,3 +107,10 @@
font-weight: var(--ds-font-weight-semibold);
line-height: var(--ds-font-line-height-1200);
}
// Focus outline
@mixin ds-focus-outline() {
box-shadow: none;
outline: 2px solid var(--ds-color-yellow-500);
outline-offset: 2px;
}