Quellcode durchsuchen

[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
Antoine Clausse vor 8 Monaten
Ursprung
Commit
0e4682ef89

+ 22 - 0
services/web/frontend/js/shared/components/ds/ds-form-control.tsx

@@ -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

+ 20 - 0
services/web/frontend/js/shared/components/ds/ds-form-feedback.tsx

@@ -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

+ 18 - 0
services/web/frontend/js/shared/components/ds/ds-form-group.tsx

@@ -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

+ 14 - 0
services/web/frontend/js/shared/components/ds/ds-form-label.tsx

@@ -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

+ 48 - 0
services/web/frontend/js/shared/components/ds/ds-form-text.tsx

@@ -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

+ 1 - 1
services/web/frontend/stories/shared/ds-button.stories.tsx

@@ -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',

+ 58 - 0
services/web/frontend/stories/shared/ds-form-control.stories.tsx

@@ -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

+ 1 - 0
services/web/frontend/stylesheets/ds/components/all.scss

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

+ 3 - 3
services/web/frontend/stylesheets/ds/components/button.scss

@@ -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

+ 85 - 0
services/web/frontend/stylesheets/ds/components/form-control.scss

@@ -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);
+  }
+}

+ 7 - 0
services/web/frontend/stylesheets/ds/mixins.scss

@@ -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;
+}