diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-09-22 19:34:01 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-10-24 12:23:48 +0200 |
| commit | a6ff5eee45215effb3344cb5d631a27a7c0369aa (patch) | |
| tree | 5051747acf72318b4fc5c18d603e3757fbefdfdb /src/components/atoms | |
| parent | 651ea4fc992e77d2f36b3c68f8e7a70644246067 (diff) | |
refactor(components): rewrite form components
Diffstat (limited to 'src/components/atoms')
58 files changed, 1339 insertions, 607 deletions
diff --git a/src/components/atoms/forms/boolean-field.module.scss b/src/components/atoms/forms/boolean-field.module.scss deleted file mode 100644 index f299ddd..0000000 --- a/src/components/atoms/forms/boolean-field.module.scss +++ /dev/null @@ -1,5 +0,0 @@ -@use "../../../styles/abstracts/mixins" as mix; - -.hidden { - @include mix.visually-hidden; -} diff --git a/src/components/atoms/forms/boolean-field.test.tsx b/src/components/atoms/forms/boolean-field.test.tsx deleted file mode 100644 index 503d1ce..0000000 --- a/src/components/atoms/forms/boolean-field.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, screen } from '../../../../tests/utils'; -import { BooleanField } from './boolean-field'; - -describe('BooleanField', () => { - it('renders an unchecked checkbox', () => { - render( - <BooleanField - checked={false} - id="jest-checkbox" - name="jest-checkbox" - onChange={() => null} - type="checkbox" - value="checkbox" - /> - ); - expect(screen.getByRole('checkbox')).not.toBeChecked(); - }); - - it('renders a checked checkbox', () => { - render( - <BooleanField - checked={true} - id="jest-checkbox" - name="jest-checkbox" - onChange={() => null} - type="checkbox" - value="checkbox" - /> - ); - expect(screen.getByRole('checkbox')).toBeChecked(); - }); - - it('renders an unchecked radio', () => { - render( - <BooleanField - checked={false} - id="jest-radio" - name="jest-radio" - onChange={() => null} - type="radio" - value="radio" - /> - ); - expect(screen.getByRole('radio')).not.toBeChecked(); - }); - - it('renders a checked radio', () => { - render( - <BooleanField - checked={true} - id="jest-radio" - name="jest-radio" - onChange={() => null} - type="radio" - value="radio" - /> - ); - expect(screen.getByRole('radio')).toBeChecked(); - }); -}); diff --git a/src/components/atoms/forms/boolean-field.tsx b/src/components/atoms/forms/boolean-field.tsx deleted file mode 100644 index 8f33a42..0000000 --- a/src/components/atoms/forms/boolean-field.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FC, InputHTMLAttributes } from 'react'; -import styles from './boolean-field.module.scss'; - -export type BooleanFieldProps = Omit< - InputHTMLAttributes<HTMLInputElement>, - 'checked' | 'hidden' | 'name' | 'type' | 'value' -> & { - /** - * True if the field should be checked. - */ - checked: boolean; - /** - * True if the field should be visually hidden. Default: false. - */ - hidden?: boolean; - /** - * Field name attribute. - */ - name: string; - /** - * The input type. - */ - type: 'checkbox' | 'radio'; - /** - * Field name attribute. - */ - value: string; -}; - -/** - * BooleanField component - * - * Render a checkbox or a radio input type. - */ -export const BooleanField: FC<BooleanFieldProps> = ({ - className = '', - hidden = false, - ...props -}) => { - const modifier = hidden ? 'hidden' : ''; - const inputClass = `${styles[modifier]} ${className}`; - - return <input {...props} className={inputClass} />; -}; diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx deleted file mode 100644 index a4553e3..0000000 --- a/src/components/atoms/forms/field.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { - ChangeEvent, - forwardRef, - ForwardRefRenderFunction, - SetStateAction, -} from 'react'; -import styles from './forms.module.scss'; - -export type FieldType = - | 'datetime-local' - | 'email' - | 'number' - | 'search' - | 'tel' - | 'text' - | 'textarea' - | 'time' - | 'url'; - -export type FieldProps = { - /** - * One or more ids that refers to the field name. - */ - 'aria-labelledby'?: string; - /** - * Add classnames to the field. - */ - className?: string; - /** - * Field state. Either enabled (false) or disabled (true). - */ - disabled?: boolean; - /** - * Field id attribute. - */ - id: string; - /** - * Field maximum value. - */ - max?: number | string; - /** - * Field minimum value. - */ - min?: number | string; - /** - * Field name attribute. - */ - name: string; - /** - * Placeholder value. - */ - placeholder?: string; - /** - * True if the field is required. Default: false. - */ - required?: boolean; - /** - * Callback function to set field value. - */ - setValue: (value: SetStateAction<string>) => void; - /** - * Field incremental values that are valid. - */ - step?: number | string; - /** - * Field type. Default: text. - */ - type: FieldType; - /** - * Field value. - */ - value: string; -}; - -const FieldWithRef: ForwardRefRenderFunction<HTMLInputElement, FieldProps> = ( - { className = '', setValue, type, ...props }, - ref -) => { - /** - * Update select value when an option is selected. - * @param e - The option change event. - */ - const updateValue = ( - e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - setValue(e.target.value); - }; - - return type === 'textarea' ? ( - <textarea - {...props} - className={`${styles.field} ${styles['field--textarea']} ${className}`} - onChange={updateValue} - /> - ) : ( - <input - {...props} - className={`${styles.field} ${className}`} - onChange={updateValue} - ref={ref} - type={type} - /> - ); -}; - -/** - * Field component. - * - * Render either an input or a textarea. - */ -export const Field = forwardRef(FieldWithRef); diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss b/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss new file mode 100644 index 0000000..7e13e43 --- /dev/null +++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.module.scss @@ -0,0 +1,7 @@ +@use "../../../../../styles/abstracts/mixins" as mix; + +.field { + &--hidden { + @include mix.visually-hidden; + } +} diff --git a/src/components/atoms/forms/boolean-field.stories.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx index 3d6f8c3..cb017da 100644 --- a/src/components/atoms/forms/boolean-field.stories.tsx +++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.stories.tsx @@ -6,10 +6,10 @@ import { BooleanField } from './boolean-field'; * BooleanField - Storybook Meta */ export default { - title: 'Atoms/Forms', + title: 'Atoms/Forms/Fields', component: BooleanField, args: { - hidden: false, + isHidden: false, }, argTypes: { 'aria-labelledby': { @@ -25,16 +25,6 @@ export default { required: false, }, }, - checked: { - control: { - type: null, - }, - description: 'The field state: true if checked.', - type: { - name: 'boolean', - required: true, - }, - }, className: { control: { type: 'text', @@ -48,7 +38,27 @@ export default { required: false, }, }, - hidden: { + id: { + control: { + type: 'text', + }, + description: 'The field id.', + type: { + name: 'string', + required: true, + }, + }, + isChecked: { + control: { + type: null, + }, + description: 'The field state: true if checked.', + type: { + name: 'boolean', + required: true, + }, + }, + isHidden: { control: { type: 'boolean', }, @@ -62,16 +72,6 @@ export default { required: false, }, }, - id: { - control: { - type: 'text', - }, - description: 'The field id.', - type: { - name: 'string', - required: true, - }, - }, name: { control: { type: 'text', @@ -133,15 +133,15 @@ export default { } as ComponentMeta<typeof BooleanField>; const Template: ComponentStory<typeof BooleanField> = ({ - checked, + isChecked: checked, onChange: _onChange, ...args }) => { - const [isChecked, setIsChecked] = useState<boolean>(checked); + const [isChecked, setIsChecked] = useState(checked); return ( <BooleanField - checked={isChecked} + isChecked={isChecked} onChange={() => { setIsChecked(!isChecked); }} @@ -156,7 +156,7 @@ const Template: ComponentStory<typeof BooleanField> = ({ export const Checkbox = Template.bind({}); Checkbox.args = { id: 'checkbox', - checked: false, + isChecked: false, name: 'checkbox', type: 'checkbox', value: 'checkbox', @@ -168,7 +168,7 @@ Checkbox.args = { export const Radio = Template.bind({}); Radio.args = { id: 'radio', - checked: false, + isChecked: false, name: 'radio', type: 'radio', value: 'radio', diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx new file mode 100644 index 0000000..fcd15ad --- /dev/null +++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '../../../../../../tests/utils'; +import { BooleanField } from './boolean-field'; + +const handleChange = () => { + /** + * Do nothing. + */ +}; + +describe('boolean field', () => { + it('renders a checkbox', () => { + render( + <BooleanField + id="checkbox" + name="checkbox" + onChange={handleChange} + type="checkbox" + value="checkbox" + /> + ); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('renders a radio button', () => { + render( + <BooleanField + id="radio" + name="radio" + onChange={handleChange} + type="radio" + value="checkbox" + /> + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx new file mode 100644 index 0000000..7985c0b --- /dev/null +++ b/src/components/atoms/forms/fields/boolean-field/boolean-field.tsx @@ -0,0 +1,86 @@ +import { FC, InputHTMLAttributes } from 'react'; +import styles from './boolean-field.module.scss'; + +export type BooleanFieldProps = Omit< + InputHTMLAttributes<HTMLInputElement>, + | 'checked' + | 'disabled' + | 'hidden' + | 'name' + | 'readOnly' + | 'required' + | 'type' + | 'value' +> & { + /** + * Should the field be checked? + * + * @default false + */ + isChecked?: boolean; + /** + * Should the field be disabled? + * + * @default false + */ + isDisabled?: boolean; + /** + * Should the field be visually hidden? + * + * @default false + */ + isHidden?: boolean; + /** + * Should the field be readonly? + * + * @default false + */ + isReadOnly?: boolean; + /** + * Should the field be required? + * + * @default false + */ + isRequired?: boolean; + /** + * Field name attribute. + */ + name: string; + /** + * The input type. + */ + type: 'checkbox' | 'radio'; + /** + * Field name attribute. + */ + value: string; +}; + +/** + * BooleanField component + * + * Render a checkbox or a radio input type. + */ +export const BooleanField: FC<BooleanFieldProps> = ({ + className = '', + isChecked = false, + isDisabled = false, + isHidden = false, + isReadOnly = false, + isRequired = false, + ...props +}) => { + const visibilityClass = isHidden ? styles['field--hidden'] : ''; + const inputClass = `${visibilityClass} ${className}`; + + return ( + <input + {...props} + checked={isChecked} + className={inputClass} + disabled={isDisabled} + readOnly={isReadOnly} + required={isRequired} + /> + ); +}; diff --git a/src/components/atoms/forms/fields/boolean-field/index.ts b/src/components/atoms/forms/fields/boolean-field/index.ts new file mode 100644 index 0000000..a49d77b --- /dev/null +++ b/src/components/atoms/forms/fields/boolean-field/index.ts @@ -0,0 +1 @@ +export * from './boolean-field'; diff --git a/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx b/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx new file mode 100644 index 0000000..658799a --- /dev/null +++ b/src/components/atoms/forms/fields/checkbox/checkbox.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '../../../../../../tests/utils'; +import { Checkbox } from './checkbox'; + +const doNothing = () => { + // Do nothing +}; + +describe('Checkbox', () => { + it('renders an unchecked checkbox', () => { + render( + <Checkbox + id="checkbox" + name="checkbox" + onChange={doNothing} + value="checkbox" + /> + ); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders a checked checkbox', () => { + render( + <Checkbox + id="checkbox" + isChecked + name="checkbox" + onChange={doNothing} + value="checkbox" + /> + ); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); +}); diff --git a/src/components/atoms/forms/fields/checkbox/checkbox.tsx b/src/components/atoms/forms/fields/checkbox/checkbox.tsx new file mode 100644 index 0000000..2ac3809 --- /dev/null +++ b/src/components/atoms/forms/fields/checkbox/checkbox.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import { BooleanField, BooleanFieldProps } from '../boolean-field'; + +export type CheckboxProps = Omit<BooleanFieldProps, 'type'>; + +/** + * Checkbox component + * + * Render a checkbox input type. + */ +export const Checkbox: FC<CheckboxProps> = (props) => ( + <BooleanField {...props} type="checkbox" /> +); diff --git a/src/components/atoms/forms/fields/checkbox/index.ts b/src/components/atoms/forms/fields/checkbox/index.ts new file mode 100644 index 0000000..8d78b3e --- /dev/null +++ b/src/components/atoms/forms/fields/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox'; diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/fields/fields.module.scss index ece26e5..f09117d 100644 --- a/src/components/atoms/forms/forms.module.scss +++ b/src/components/atoms/forms/fields/fields.module.scss @@ -1,13 +1,8 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; - -.item { - margin: var(--spacing-xs) 0; - width: 100%; - max-width: 45ch; -} +@use "../../../../styles/abstracts/functions" as fun; +@use "../../../../styles/abstracts/mixins" as mix; .field { + width: 100%; padding: var(--spacing-2xs) var(--spacing-xs); background: var(--color-bg-tertiary); border: fun.convert-px(2) solid var(--color-border); @@ -47,7 +42,9 @@ box-shadow: 0 0 0 0 var(--color-shadow); transform: translate(#{fun.convert-px(3)}, #{fun.convert-px(3)}); outline: none; - transition: all 0.2s ease-in-out 0s, transform 0.3s ease-out 0s; + transition: + all 0.2s ease-in-out 0s, + transform 0.3s ease-out 0s; } } } diff --git a/src/components/atoms/forms/fields/index.ts b/src/components/atoms/forms/fields/index.ts new file mode 100644 index 0000000..7fafba1 --- /dev/null +++ b/src/components/atoms/forms/fields/index.ts @@ -0,0 +1,6 @@ +export * from './boolean-field'; +export * from './checkbox'; +export * from './input'; +export * from './radio'; +export * from './select'; +export * from './text-area'; diff --git a/src/components/atoms/forms/fields/input/index.ts b/src/components/atoms/forms/fields/input/index.ts new file mode 100644 index 0000000..e3365cb --- /dev/null +++ b/src/components/atoms/forms/fields/input/index.ts @@ -0,0 +1 @@ +export * from './input'; diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/fields/input/input.stories.tsx index 27fd3be..35657f8 100644 --- a/src/components/atoms/forms/field.stories.tsx +++ b/src/components/atoms/forms/fields/input/input.stories.tsx @@ -1,16 +1,16 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useState } from 'react'; -import { Field } from './field'; +import { ChangeEvent, useCallback, useState } from 'react'; +import { Input } from './input'; /** - * Field - Storybook Meta + * Input - Storybook Meta */ export default { title: 'Atoms/Forms/Fields', - component: Field, + component: Input, args: { - disabled: false, - required: false, + isDisabled: false, + isRequired: false, }, argTypes: { 'aria-labelledby': { @@ -39,11 +39,21 @@ export default { required: false, }, }, - disabled: { + id: { + control: { + type: 'text', + }, + description: 'Input id.', + type: { + name: 'string', + required: true, + }, + }, + isDisabled: { control: { type: 'boolean', }, - description: 'Field state: either enabled or disabled.', + description: 'Input state: either enabled or disabled.', table: { category: 'Options', defaultValue: { summary: false }, @@ -53,14 +63,18 @@ export default { required: false, }, }, - id: { + isRequired: { control: { - type: 'text', + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, }, - description: 'Field id.', type: { - name: 'string', - required: true, + name: 'boolean', + required: false, }, }, max: { @@ -93,7 +107,7 @@ export default { control: { type: 'text', }, - description: 'Field name.', + description: 'Input name.', type: { name: 'string', required: true, @@ -112,38 +126,11 @@ export default { required: false, }, }, - required: { - control: { - type: 'boolean', - }, - description: 'Determine if the field is required.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - setValue: { - control: { - type: null, - }, - description: 'Callback function to set field value.', - table: { - category: 'Events', - }, - type: { - name: 'function', - required: true, - }, - }, step: { control: { type: 'number', }, - description: 'Field incremental values that are valid.', + description: 'Input incremental values that are valid.', table: { category: 'Options', }, @@ -156,7 +143,7 @@ export default { control: { type: 'select', }, - description: 'Field type: input type or textarea.', + description: 'Input type: input type or textarea.', options: [ 'datetime-local', 'email', @@ -177,27 +164,30 @@ export default { control: { type: null, }, - description: 'Field value.', + description: 'Input value.', type: { name: 'string', required: true, }, }, }, -} as ComponentMeta<typeof Field>; +} as ComponentMeta<typeof Input>; -const Template: ComponentStory<typeof Field> = ({ - value: _value, - setValue: _setValue, +const Template: ComponentStory<typeof Input> = ({ + value: initialValue, + onChange: _onChange, ...args }) => { - const [value, setValue] = useState<string>(''); + const [value, setValue] = useState(initialValue); + const updateValue = useCallback((e: ChangeEvent<HTMLInputElement>) => { + setValue(e.target.value); + }, []); - return <Field value={value} setValue={setValue} {...args} />; + return <Input value={value} onChange={updateValue} {...args} />; }; /** - * Field Story - DateTime + * Input Story - DateTime */ export const DateTime = Template.bind({}); DateTime.args = { @@ -207,7 +197,7 @@ DateTime.args = { }; /** - * Field Story - Email + * Input Story - Email */ export const Email = Template.bind({}); Email.args = { @@ -217,37 +207,27 @@ Email.args = { }; /** - * Field Story - Text + * Input Story - Numeric */ -export const Text = Template.bind({}); -Text.args = { - id: 'field-storybook', - name: 'field-storybook', - type: 'text', -}; - -/** - * Field Story - Number - */ -export const Number = Template.bind({}); -Number.args = { +export const Numeric = Template.bind({}); +Numeric.args = { id: 'field-storybook', name: 'field-storybook', type: 'number', }; /** - * Field Story - TextArea + * Input Story - Text */ -export const TextArea = Template.bind({}); -TextArea.args = { +export const Text = Template.bind({}); +Text.args = { id: 'field-storybook', name: 'field-storybook', - type: 'textarea', + type: 'text', }; /** - * Field Story - Time + * Input Story - Time */ export const Time = Template.bind({}); Time.args = { diff --git a/src/components/atoms/forms/field.test.tsx b/src/components/atoms/forms/fields/input/input.test.tsx index 492aa48..1692c9e 100644 --- a/src/components/atoms/forms/field.test.tsx +++ b/src/components/atoms/forms/fields/input/input.test.tsx @@ -1,15 +1,19 @@ -import { render, screen } from '../../../../tests/utils'; -import { Field } from './field'; +import { render, screen } from '../../../../../../tests/utils'; +import { Input } from './input'; -describe('Field', () => { +const doNothing = () => { + // do nothing +}; + +describe('Input', () => { it('renders a text input', () => { render( - <Field + <Input id="text-field" name="text-field" + onChange={doNothing} type="text" value="" - setValue={() => null} /> ); expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); @@ -17,12 +21,12 @@ describe('Field', () => { it('renders a search input', () => { render( - <Field + <Input id="search-field" name="search-field" + onChange={doNothing} type="search" value="" - setValue={() => null} /> ); expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search'); diff --git a/src/components/atoms/forms/fields/input/input.tsx b/src/components/atoms/forms/fields/input/input.tsx new file mode 100644 index 0000000..0f0736c --- /dev/null +++ b/src/components/atoms/forms/fields/input/input.tsx @@ -0,0 +1,72 @@ +import { + type ForwardedRef, + forwardRef, + type InputHTMLAttributes, + type HTMLInputTypeAttribute, +} from 'react'; +import styles from '../fields.module.scss'; + +export type InputProps = Omit< + InputHTMLAttributes<HTMLInputElement>, + 'disabled' | 'hidden' | 'readonly' | 'required' | 'type' +> & + Required<Pick<InputHTMLAttributes<HTMLInputElement>, 'id' | 'name'>> & { + /** + * Should the field be disabled? + * + * @default false + */ + isDisabled?: boolean; + /** + * Should the field be hidden? + * + * @default false + */ + isHidden?: boolean; + /** + * Should the field be readonly? + * + * @default false + */ + isReadOnly?: boolean; + /** + * Should the field be required? + * + * @default false + */ + isRequired?: boolean; + /** + * The input type. + */ + type: Exclude<HTMLInputTypeAttribute, 'checkbox' | 'radio' | 'range'>; + }; + +const InputWithRef = ( + { + className = '', + isDisabled = false, + isHidden = false, + isReadOnly = false, + isRequired = false, + ...props + }: InputProps, + ref: ForwardedRef<HTMLInputElement> +) => { + const fieldClassName = `${styles.field} ${className}`; + + return ( + <input + {...props} + className={fieldClassName} + disabled={isDisabled} + readOnly={isReadOnly} + ref={ref} + required={isRequired} + /> + ); +}; + +/** + * Input component. + */ +export const Input = forwardRef(InputWithRef); diff --git a/src/components/atoms/forms/fields/radio/index.ts b/src/components/atoms/forms/fields/radio/index.ts new file mode 100644 index 0000000..1140e08 --- /dev/null +++ b/src/components/atoms/forms/fields/radio/index.ts @@ -0,0 +1 @@ +export * from './radio'; diff --git a/src/components/atoms/forms/fields/radio/radio.test.tsx b/src/components/atoms/forms/fields/radio/radio.test.tsx new file mode 100644 index 0000000..42df991 --- /dev/null +++ b/src/components/atoms/forms/fields/radio/radio.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '../../../../../../tests/utils'; +import { Radio } from './radio'; + +const doNothing = () => { + // Do nothing +}; + +describe('Radio', () => { + it('renders an unchecked radio', () => { + render( + <Radio id="radio" name="radio" onChange={doNothing} value="radio" /> + ); + expect(screen.getByRole('radio')).not.toBeChecked(); + }); + + it('renders a checked radio', () => { + render( + <Radio + id="radio" + isChecked + name="radio" + onChange={doNothing} + value="radio" + /> + ); + expect(screen.getByRole('radio')).toBeChecked(); + }); +}); diff --git a/src/components/atoms/forms/fields/radio/radio.tsx b/src/components/atoms/forms/fields/radio/radio.tsx new file mode 100644 index 0000000..6430b4a --- /dev/null +++ b/src/components/atoms/forms/fields/radio/radio.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; +import { BooleanField, BooleanFieldProps } from '../boolean-field'; + +export type RadioProps = Omit<BooleanFieldProps, 'type'>; + +/** + * Radio component + * + * Render a radio input type. + */ +export const Radio: FC<RadioProps> = (props) => ( + <BooleanField {...props} type="radio" /> +); diff --git a/src/components/atoms/forms/fields/select/index.ts b/src/components/atoms/forms/fields/select/index.ts new file mode 100644 index 0000000..c739673 --- /dev/null +++ b/src/components/atoms/forms/fields/select/index.ts @@ -0,0 +1 @@ +export * from './select'; diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/fields/select/select.stories.tsx index b98ebed..c9e02d2 100644 --- a/src/components/atoms/forms/select.stories.tsx +++ b/src/components/atoms/forms/fields/select/select.stories.tsx @@ -1,5 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useState } from 'react'; +import { ChangeEvent, useCallback, useState } from 'react'; import { Select as SelectComponent } from './select'; const selectOptions = [ @@ -12,11 +12,11 @@ const selectOptions = [ * Select - Storybook Meta */ export default { - title: 'Atoms/Forms', + title: 'Atoms/Forms/Fields', component: SelectComponent, args: { - disabled: false, - required: false, + isDisabled: false, + isRequired: false, }, argTypes: { 'aria-labelledby': { @@ -45,7 +45,17 @@ export default { required: false, }, }, - disabled: { + id: { + control: { + type: 'text', + }, + description: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + isDisabled: { control: { type: 'boolean', }, @@ -59,14 +69,18 @@ export default { required: false, }, }, - id: { + isRequired: { control: { - type: 'text', + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, }, - description: 'Field id.', type: { - name: 'string', - required: true, + name: 'boolean', + required: false, }, }, name: { @@ -89,33 +103,6 @@ export default { }, }, }, - required: { - control: { - type: 'boolean', - }, - description: 'Determine if the field is required.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - required: false, - }, - }, - setValue: { - control: { - type: null, - }, - description: 'Callback function to set field value.', - table: { - category: 'Events', - }, - type: { - name: 'function', - required: true, - }, - }, value: { control: { type: null, @@ -130,13 +117,18 @@ export default { } as ComponentMeta<typeof SelectComponent>; const Template: ComponentStory<typeof SelectComponent> = ({ + onChange: _onChange, value, - setValue: _setValue, ...args }) => { - const [selected, setSelected] = useState<string>(value); + const [selected, setSelected] = useState(value); + const updateSelection = useCallback((e: ChangeEvent<HTMLSelectElement>) => { + setSelected(e.target.value); + }, []); - return <SelectComponent value={selected} setValue={setSelected} {...args} />; + return ( + <SelectComponent {...args} onChange={updateSelection} value={selected} /> + ); }; /** diff --git a/src/components/atoms/forms/select.test.tsx b/src/components/atoms/forms/fields/select/select.test.tsx index 53d9b1f..088cc9e 100644 --- a/src/components/atoms/forms/select.test.tsx +++ b/src/components/atoms/forms/fields/select/select.test.tsx @@ -1,6 +1,10 @@ -import { render, screen } from '../../../../tests/utils'; +import { render, screen } from '../../../../../../tests/utils'; import { Select } from './select'; +const doNothing = () => { + // do nothing +}; + const selectOptions = [ { id: 'option1', name: 'Option 1', value: 'option1' }, { id: 'option2', name: 'Option 2', value: 'option2' }, @@ -12,19 +16,28 @@ describe('Select', () => { it('should correctly set default option', () => { render( <Select - id="jest-select" - name="jest-select" + id="select-1" + name="select-1" + onChange={doNothing} options={selectOptions} value={selected.value} - setValue={() => null} /> ); + expect(screen.getByRole('combobox')).toHaveValue(selected.value); - expect(screen.queryByRole('combobox')).not.toHaveValue( - selectOptions[1].value - ); - expect(screen.queryByRole('combobox')).not.toHaveValue( - selectOptions[2].value + }); + + it('renders the select options', () => { + render( + <Select + id="select-2" + name="select-2" + onChange={doNothing} + options={selectOptions} + value={selected.value} + /> ); + + expect(screen.getAllByRole('option')).toHaveLength(selectOptions.length); }); }); diff --git a/src/components/atoms/forms/fields/select/select.tsx b/src/components/atoms/forms/fields/select/select.tsx new file mode 100644 index 0000000..887dacc --- /dev/null +++ b/src/components/atoms/forms/fields/select/select.tsx @@ -0,0 +1,76 @@ +import { FC, SelectHTMLAttributes } from 'react'; +import styles from '../fields.module.scss'; + +export type SelectOptions = { + /** + * The option id. + */ + id: string; + /** + * The option name. + */ + name: string; + /** + * The option value. + */ + value: string; +}; + +export type SelectProps = Omit< + SelectHTMLAttributes<HTMLSelectElement>, + 'disabled' | 'hidden' | 'required' +> & { + /** + * Should the select field be disabled? + * + * @default false + */ + isDisabled?: boolean; + /** + * Should the select field be hidden? + * + * @default false + */ + isHidden?: boolean; + /** + * Is the select field required? + * + * @default false + */ + isRequired?: boolean; + /** + * True if the field is required. Default: false. + */ + options: SelectOptions[]; +}; + +/** + * Select component + * + * Render a HTML select element. + */ +export const Select: FC<SelectProps> = ({ + className = '', + isDisabled = false, + isHidden = false, + isRequired = false, + options, + ...props +}) => { + const selectClass = `${styles.field} ${styles['field--select']} ${className}`; + + return ( + <select + {...props} + className={selectClass} + disabled={isDisabled} + required={isRequired} + > + {options.map((option) => ( + <option key={option.id} id={option.id} value={option.value}> + {option.name} + </option> + ))} + </select> + ); +}; diff --git a/src/components/atoms/forms/fields/text-area/index.ts b/src/components/atoms/forms/fields/text-area/index.ts new file mode 100644 index 0000000..e18b325 --- /dev/null +++ b/src/components/atoms/forms/fields/text-area/index.ts @@ -0,0 +1 @@ +export * from './text-area'; diff --git a/src/components/atoms/forms/fields/text-area/text-area.stories.tsx b/src/components/atoms/forms/fields/text-area/text-area.stories.tsx new file mode 100644 index 0000000..2e77cb7 --- /dev/null +++ b/src/components/atoms/forms/fields/text-area/text-area.stories.tsx @@ -0,0 +1,136 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { ChangeEvent, useCallback, useState } from 'react'; +import { TextArea as TextAreaComponent } from './text-area'; + +/** + * TextArea - Storybook Meta + */ +export default { + title: 'Atoms/Forms/Fields', + component: TextAreaComponent, + args: { + isDisabled: false, + isRequired: false, + }, + argTypes: { + 'aria-labelledby': { + control: { + type: 'text', + }, + description: 'One or more ids that refers to the field name.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'TextArea id.', + type: { + name: 'string', + required: true, + }, + }, + isDisabled: { + control: { + type: 'boolean', + }, + description: 'TextArea state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + isRequired: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'TextArea name.', + type: { + name: 'string', + required: true, + }, + }, + placeholder: { + control: { + type: 'text', + }, + description: 'A placeholder value.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + value: { + control: { + type: null, + }, + description: 'TextArea value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof TextAreaComponent>; + +const Template: ComponentStory<typeof TextAreaComponent> = ({ + value: initialValue, + onChange: _onChange, + ...args +}) => { + const [value, setValue] = useState(initialValue); + const updateValue = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => { + setValue(e.target.value); + }, []); + + return <TextAreaComponent value={value} onChange={updateValue} {...args} />; +}; + +/** + * TextArea Story - TextArea + */ +export const TextArea = Template.bind({}); +TextArea.args = { + id: 'field-storybook', + name: 'field-storybook', +}; diff --git a/src/components/atoms/forms/fields/text-area/text-area.test.tsx b/src/components/atoms/forms/fields/text-area/text-area.test.tsx new file mode 100644 index 0000000..37a1d1c --- /dev/null +++ b/src/components/atoms/forms/fields/text-area/text-area.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '../../../../../../tests/utils'; +import { TextArea } from './text-area'; + +const doNothing = () => { + // do nothing +}; + +describe('TextArea', () => { + it('renders a textarea', () => { + render( + <TextArea + id="textarea-field" + name="textarea-field" + onChange={doNothing} + value="" + /> + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/forms/fields/text-area/text-area.tsx b/src/components/atoms/forms/fields/text-area/text-area.tsx new file mode 100644 index 0000000..bd99d7d --- /dev/null +++ b/src/components/atoms/forms/fields/text-area/text-area.tsx @@ -0,0 +1,69 @@ +import { + type ForwardedRef, + forwardRef, + type TextareaHTMLAttributes, +} from 'react'; +import styles from '../fields.module.scss'; + +type AllowedTextAreaProps = Omit< + TextareaHTMLAttributes<HTMLTextAreaElement>, + 'disabled' | 'readOnly' | 'required' +> & + Required<Pick<TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'name'>>; + +export type TextAreaProps = AllowedTextAreaProps & { + /** + * Should the field be disabled? + * + * @default false + */ + isDisabled?: boolean; + /** + * Should the field be hidden? + * + * @default false + */ + isHidden?: boolean; + /** + * Should the field be readonly? + * + * @default false + */ + isReadOnly?: boolean; + /** + * Should the field be required? + * + * @default false + */ + isRequired?: boolean; +}; + +const TextAreaWithRef = ( + { + className = '', + isDisabled = false, + isHidden = false, + isReadOnly = false, + isRequired = false, + ...props + }: TextAreaProps, + ref: ForwardedRef<HTMLTextAreaElement> +) => { + const fieldClassName = `${styles.field} ${styles['field--textarea']} ${className}`; + + return ( + <textarea + {...props} + className={fieldClassName} + disabled={isDisabled} + readOnly={isReadOnly} + ref={ref} + required={isRequired} + /> + ); +}; + +/** + * TextArea component. + */ +export const TextArea = forwardRef(TextAreaWithRef); diff --git a/src/components/atoms/forms/fieldset/fieldset.module.scss b/src/components/atoms/forms/fieldset/fieldset.module.scss new file mode 100644 index 0000000..ed545a7 --- /dev/null +++ b/src/components/atoms/forms/fieldset/fieldset.module.scss @@ -0,0 +1,17 @@ +.fieldset { + display: flex; + gap: var(--spacing-2xs); + max-width: 100%; + margin: 0; + padding: 0; + border: none; + + &--inline { + flex-flow: row wrap; + align-items: center; + } + + &--stack { + flex-flow: column wrap; + } +} diff --git a/src/components/atoms/forms/fieldset/fieldset.stories.tsx b/src/components/atoms/forms/fieldset/fieldset.stories.tsx new file mode 100644 index 0000000..faf355f --- /dev/null +++ b/src/components/atoms/forms/fieldset/fieldset.stories.tsx @@ -0,0 +1,63 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Fieldset as FieldsetComponent } from './fieldset'; +import { Input } from '../fields'; +import { Legend } from '../legend'; + +/** + * Fieldset - Storybook Meta + */ +export default { + title: 'Atoms/Forms', + component: FieldsetComponent, + args: { + isDisabled: false, + }, + argTypes: { + isDisabled: { + control: { + type: 'boolean', + }, + description: + 'Define if the fields inside the fieldset should be disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + }, +} as ComponentMeta<typeof FieldsetComponent>; + +const Template: ComponentStory<typeof FieldsetComponent> = (args) => { + return ( + <FieldsetComponent {...args}> + <div> + <Input + aria-label="A field example" + id="field1" + name="field1" + type="text" + /> + </div> + <div> + <Input + aria-label="Another field example" + id="field2" + name="field2" + type="text" + /> + </div> + </FieldsetComponent> + ); +}; + +/** + * Fieldset Story + */ +export const Fieldset = Template.bind({}); +Fieldset.args = { + legend: <Legend>The fieldset legend</Legend>, +}; diff --git a/src/components/atoms/forms/fieldset/fieldset.test.tsx b/src/components/atoms/forms/fieldset/fieldset.test.tsx new file mode 100644 index 0000000..08a0aaa --- /dev/null +++ b/src/components/atoms/forms/fieldset/fieldset.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from '../../../../../tests/utils'; +import { Input } from '../fields'; +import { Fieldset } from './fieldset'; + +describe('fieldset', () => { + it('renders a fieldset', () => { + render( + <Fieldset> + <Input + aria-label="A field example" + id="field" + name="field" + type="text" + /> + </Fieldset> + ); + expect(screen.getByRole('group')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).not.toBeDisabled(); + }); + + it('renders a disabled fieldset', () => { + render( + <Fieldset isDisabled> + <Input + aria-label="A field example" + id="field" + name="field" + type="text" + /> + </Fieldset> + ); + expect(screen.getByRole('group')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeDisabled(); + }); +}); diff --git a/src/components/atoms/forms/fieldset/fieldset.tsx b/src/components/atoms/forms/fieldset/fieldset.tsx new file mode 100644 index 0000000..eb42961 --- /dev/null +++ b/src/components/atoms/forms/fieldset/fieldset.tsx @@ -0,0 +1,68 @@ +import { + forwardRef, + type FieldsetHTMLAttributes, + ForwardRefRenderFunction, + ReactElement, +} from 'react'; +import styles from './fieldset.module.scss'; +import { LegendProps } from '../legend'; + +export type FieldsetProps = Omit< + FieldsetHTMLAttributes<HTMLFieldSetElement>, + 'disabled' | 'hidden' +> & { + /** + * Should the fieldset be disabled? + * + * @default false + */ + isDisabled?: boolean; + /** + * Should the fieldset contents be inlined? + * + * @default false + */ + isInline?: boolean; + /** + * The fieldset legend. + */ + legend?: ReactElement<LegendProps>; +}; + +/** + * Fieldset component. + */ +const FieldsetWithRef: ForwardRefRenderFunction< + HTMLFieldSetElement, + FieldsetProps +> = ( + { + children, + className = '', + isDisabled = false, + isInline = false, + legend, + ...props + }, + ref +) => { + const layoutModifier = isInline + ? styles['fieldset--inline'] + : styles['fieldset--stack']; + const legendModifier = legend ? styles['fieldset--has-legend'] : ''; + const fieldsetClass = `${styles.fieldset} ${legendModifier} ${layoutModifier} ${className}`; + + return ( + <fieldset + {...props} + className={fieldsetClass} + disabled={isDisabled} + ref={ref} + > + {legend} + {children} + </fieldset> + ); +}; + +export const Fieldset = forwardRef(FieldsetWithRef); diff --git a/src/components/atoms/forms/fieldset/index.ts b/src/components/atoms/forms/fieldset/index.ts new file mode 100644 index 0000000..00ef1f8 --- /dev/null +++ b/src/components/atoms/forms/fieldset/index.ts @@ -0,0 +1 @@ +export * from './fieldset'; diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx deleted file mode 100644 index b040665..0000000 --- a/src/components/atoms/forms/form.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { render, screen } from '../../../../tests/utils'; -import { Form } from './form'; - -describe('Form', () => { - it('renders a form', () => { - render( - <Form aria-label="Jest form" onSubmit={() => null}> - Fields - </Form> - ); - expect(screen.getByRole('form', { name: 'Jest form' })).toBeInTheDocument(); - }); -}); diff --git a/src/components/atoms/forms/form.tsx b/src/components/atoms/forms/form.tsx deleted file mode 100644 index 85ff8fd..0000000 --- a/src/components/atoms/forms/form.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - Children, - FC, - FormEvent, - FormHTMLAttributes, - Fragment, - ReactNode, -} from 'react'; -import styles from './forms.module.scss'; - -export type FormProps = Omit< - FormHTMLAttributes<HTMLFormElement>, - 'onSubmit' -> & { - /** - * The form body. - */ - children: ReactNode; - /** - * Wrap each items with a div. Default: true. - */ - grouped?: boolean; - /** - * If grouped, set additional classnames to the items wrapper. - */ - itemsClassName?: string; - /** - * A callback function to execute on submit. - */ - onSubmit: () => void; -}; - -/** - * Form component. - * - * Render children wrapped in a form element. - */ -export const Form: FC<FormProps> = ({ - children, - grouped = true, - itemsClassName = '', - onSubmit, - ...props -}) => { - const arrayChildren = Children.toArray(children); - - /** - * Get the form items. - * @returns {JSX.Element[]} An array of child elements wrapped in a div. - */ - const getFormItems = (): JSX.Element[] => { - return arrayChildren.map((child, index) => - grouped ? ( - <div - key={`item-${index}`} - className={`${styles.item} ${itemsClassName}`} - > - {child} - </div> - ) : ( - <Fragment key={`item-${index}`}>{child}</Fragment> - ) - ); - }; - - /** - * Handle form submit. - * @param {FormEvent} e - The form event. - */ - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - onSubmit(); - }; - - return ( - <form {...props} onSubmit={handleSubmit}> - {getFormItems()} - </form> - ); -}; diff --git a/src/components/atoms/forms/form/form.test.tsx b/src/components/atoms/forms/form/form.test.tsx new file mode 100644 index 0000000..08165f5 --- /dev/null +++ b/src/components/atoms/forms/form/form.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '../../../../../tests/utils'; +import { Form } from './form'; + +describe('Form', () => { + it('renders a form', () => { + render( + <Form aria-label="A form name" onSubmit={() => null}> + Fields + </Form> + ); + expect(screen.getByRole('form')).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/forms/form/form.tsx b/src/components/atoms/forms/form/form.tsx new file mode 100644 index 0000000..86481d2 --- /dev/null +++ b/src/components/atoms/forms/form/form.tsx @@ -0,0 +1,28 @@ +import { + type FormHTMLAttributes, + forwardRef, + type ForwardRefRenderFunction, +} from 'react'; + +export type FormRole = 'form' | 'search' | 'none' | 'presentation'; + +export type FormProps = FormHTMLAttributes<HTMLFormElement> & { + /** + * An accessible role. + */ + role?: FormRole; +}; + +const FormWithRef: ForwardRefRenderFunction<HTMLFormElement, FormProps> = ( + { children, ...props }, + ref +) => ( + <form {...props} ref={ref}> + {children} + </form> +); + +/** + * Form component. + */ +export const Form = forwardRef(FormWithRef); diff --git a/src/components/atoms/forms/form/index.ts b/src/components/atoms/forms/form/index.ts new file mode 100644 index 0000000..698d687 --- /dev/null +++ b/src/components/atoms/forms/form/index.ts @@ -0,0 +1 @@ +export * from './form'; diff --git a/src/components/atoms/forms/index.ts b/src/components/atoms/forms/index.ts index 0af138f..7e444c2 100644 --- a/src/components/atoms/forms/index.ts +++ b/src/components/atoms/forms/index.ts @@ -1,5 +1,5 @@ -export * from './boolean-field'; -export * from './field'; +export * from './fields'; +export * from './fieldset'; export * from './form'; export * from './label'; -export * from './select'; +export * from './legend'; diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx deleted file mode 100644 index 6764579..0000000 --- a/src/components/atoms/forms/label.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { FC, LabelHTMLAttributes, ReactNode } from 'react'; -import styles from './label.module.scss'; - -export type LabelProps = LabelHTMLAttributes<HTMLLabelElement> & { - /** - * The label body. - */ - children: ReactNode; - /** - * Is the field required? Default: false. - */ - required?: boolean; - /** - * The label size. Default: small. - */ - size?: 'medium' | 'small'; -}; - -/** - * Label Component - * - * Render a HTML label element. - */ -export const Label: FC<LabelProps> = ({ - children, - className = '', - required = false, - size = 'small', - ...props -}) => { - const sizeClass = styles[`label--${size}`]; - const labelClass = `${styles.label} ${sizeClass} ${className}`; - - return ( - <label {...props} className={labelClass}> - {children} - {required && <span className={styles.required}> *</span>} - </label> - ); -}; diff --git a/src/components/atoms/forms/label/index.ts b/src/components/atoms/forms/label/index.ts new file mode 100644 index 0000000..301fbde --- /dev/null +++ b/src/components/atoms/forms/label/index.ts @@ -0,0 +1 @@ +export * from './label'; diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label/label.module.scss index aed1546..21ba9d3 100644 --- a/src/components/atoms/forms/label.module.scss +++ b/src/components/atoms/forms/label/label.module.scss @@ -3,12 +3,12 @@ font-weight: 600; cursor: pointer; - &--small { + &--sm { font-size: var(--font-size-sm); font-variant: small-caps; } - &--medium { + &--md { font-size: var(--font-size-md); } } diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label/label.stories.tsx index 3adc92a..8460c45 100644 --- a/src/components/atoms/forms/label.stories.tsx +++ b/src/components/atoms/forms/label/label.stories.tsx @@ -8,8 +8,9 @@ export default { title: 'Atoms/Forms', component: LabelComponent, args: { - required: false, - size: 'small', + isHidden: false, + isRequired: false, + size: 'sm', }, argTypes: { 'aria-label': { @@ -58,7 +59,21 @@ export default { required: true, }, }, - required: { + isHidden: { + control: { + type: 'boolean', + }, + description: 'Set to true if the label should be visually hidden.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + isRequired: { control: { type: 'boolean', }, @@ -77,10 +92,10 @@ export default { type: 'select', }, description: 'The label size.', - options: ['medium', 'small'], + options: ['md', 'sm'], table: { category: 'Options', - defaultValue: { summary: 'small' }, + defaultValue: { summary: 'sm' }, }, type: { name: 'string', diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label/label.test.tsx index 091737b..afdbb94 100644 --- a/src/components/atoms/forms/label.test.tsx +++ b/src/components/atoms/forms/label/label.test.tsx @@ -1,9 +1,9 @@ -import { render, screen } from '../../../../tests/utils'; +import { render, screen } from '../../../../../tests/utils'; import { Label } from './label'; describe('Label', () => { it('renders a field label', () => { render(<Label>A label</Label>); - expect(screen.getByText('A label')).toBeDefined(); + expect(screen.getByText('A label')).toBeInTheDocument(); }); }); diff --git a/src/components/atoms/forms/label/label.tsx b/src/components/atoms/forms/label/label.tsx new file mode 100644 index 0000000..5087325 --- /dev/null +++ b/src/components/atoms/forms/label/label.tsx @@ -0,0 +1,62 @@ +import { FC, LabelHTMLAttributes, ReactNode } from 'react'; +import styles from './label.module.scss'; + +export type LabelSize = 'md' | 'sm'; + +export type LabelProps = Omit< + LabelHTMLAttributes<HTMLLabelElement>, + 'hidden' | 'size' +> & { + /** + * The label body. + */ + children: ReactNode; + /** + * Should the label be hidden? + * + * @default false + */ + isHidden?: boolean; + /** + * Is the field required? + * + * @default false + */ + isRequired?: boolean; + /** + * The label size. + * + * @default 'sm' + */ + size?: LabelSize; +}; + +/** + * Label Component + * + * Render a HTML label element. + */ +export const Label: FC<LabelProps> = ({ + children, + className = '', + isHidden = false, + isRequired = false, + size = 'sm', + ...props +}) => { + const visibilityClass = isHidden ? 'screen-reader-text' : ''; + const sizeClass = styles[`label--${size}`]; + const labelClass = `${styles.label} ${sizeClass} ${visibilityClass} ${className}`; + const requiredSymbol = ' *'; + + return ( + <label {...props} className={labelClass}> + {children} + {isRequired ? ( + <span aria-hidden className={styles.required}> + {requiredSymbol} + </span> + ) : null} + </label> + ); +}; diff --git a/src/components/atoms/forms/legend/index.ts b/src/components/atoms/forms/legend/index.ts new file mode 100644 index 0000000..a0482ef --- /dev/null +++ b/src/components/atoms/forms/legend/index.ts @@ -0,0 +1 @@ +export * from './legend'; diff --git a/src/components/atoms/forms/legend/legend.module.scss b/src/components/atoms/forms/legend/legend.module.scss new file mode 100644 index 0000000..705e3fe --- /dev/null +++ b/src/components/atoms/forms/legend/legend.module.scss @@ -0,0 +1,6 @@ +.legend { + float: left; + font-size: var(--font-size-md); + font-weight: 600; + color: var(--color-primary-darker); +} diff --git a/src/components/atoms/forms/legend/legend.stories.tsx b/src/components/atoms/forms/legend/legend.stories.tsx new file mode 100644 index 0000000..cda7f09 --- /dev/null +++ b/src/components/atoms/forms/legend/legend.stories.tsx @@ -0,0 +1,27 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Legend as LegendComponent } from './legend'; +import { Fieldset } from '../fieldset'; + +/** + * Legend - Storybook Meta + */ +export default { + title: 'Atoms/Forms', + component: LegendComponent, + args: {}, + argTypes: {}, +} as ComponentMeta<typeof LegendComponent>; + +const Template: ComponentStory<typeof LegendComponent> = (args) => ( + <Fieldset> + <LegendComponent {...args} /> + </Fieldset> +); + +/** + * Legend Story + */ +export const Legend = Template.bind({}); +Legend.args = { + children: 'A fieldset legend', +}; diff --git a/src/components/atoms/forms/legend/legend.test.tsx b/src/components/atoms/forms/legend/legend.test.tsx new file mode 100644 index 0000000..7abb996 --- /dev/null +++ b/src/components/atoms/forms/legend/legend.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from '../../../../../tests/utils'; +import { Fieldset } from '../fieldset'; +import { Legend } from './legend'; + +describe('legend', () => { + it('renders the fieldset legend', () => { + const body = 'deserunt'; + + render( + <Fieldset> + <Legend>{body}</Legend> + </Fieldset> + ); + + expect(screen.getByRole('group')).toHaveTextContent(body); + }); +}); diff --git a/src/components/atoms/forms/legend/legend.tsx b/src/components/atoms/forms/legend/legend.tsx new file mode 100644 index 0000000..b517855 --- /dev/null +++ b/src/components/atoms/forms/legend/legend.tsx @@ -0,0 +1,21 @@ +import type { FC, HTMLAttributes } from 'react'; +import styles from './legend.module.scss'; + +export type LegendProps = HTMLAttributes<HTMLLegendElement>; + +/** + * Legend component. + */ +export const Legend: FC<LegendProps> = ({ + children, + className = '', + ...props +}) => { + const legendClass = `${styles.legend} ${className}`; + + return ( + <legend {...props} className={legendClass}> + {children} + </legend> + ); +}; diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx deleted file mode 100644 index 14f85dc..0000000 --- a/src/components/atoms/forms/select.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { ChangeEvent, FC, SelectHTMLAttributes, SetStateAction } from 'react'; -import styles from './forms.module.scss'; - -export type SelectOptions = { - /** - * The option id. - */ - id: string; - /** - * The option name. - */ - name: string; - /** - * The option value. - */ - value: string; -}; - -export type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & { - /** - * Field id attribute. - */ - id: string; - /** - * Field name attribute. - */ - name: string; - /** - * True if the field is required. Default: false. - */ - options: SelectOptions[]; - /** - * Callback function to set field value. - */ - setValue: (value: SetStateAction<string>) => void; - /** - * Field value. - */ - value: string; -}; - -/** - * Select component - * - * Render a HTML select element. - */ -export const Select: FC<SelectProps> = ({ - className = '', - options, - setValue, - ...props -}) => { - const selectClass = `${styles.field} ${styles['field--select']} ${className}`; - - /** - * Update select value when an option is selected. - * @param e - The option change event. - */ - const updateValue = (e: ChangeEvent<HTMLSelectElement>) => { - setValue(e.target.value); - }; - - /** - * Get the option elements. - * @returns {JSX.Element[]} An array of HTML option elements. - */ - const getOptions = (): JSX.Element[] => - options.map((option) => ( - <option key={option.id} value={option.value}> - {option.name} - </option> - )); - - return ( - <select {...props} className={selectClass} onChange={updateValue}> - {getOptions()} - </select> - ); -}; diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index 72b5598..d9cf865 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -7,3 +7,4 @@ export * from './layout'; export * from './links'; export * from './lists'; export * from './loaders'; +export * from './modal'; diff --git a/src/components/atoms/modal/index.ts b/src/components/atoms/modal/index.ts new file mode 100644 index 0000000..133aa74 --- /dev/null +++ b/src/components/atoms/modal/index.ts @@ -0,0 +1 @@ +export * from './modal'; diff --git a/src/components/atoms/modal/modal.module.scss b/src/components/atoms/modal/modal.module.scss new file mode 100644 index 0000000..6650235 --- /dev/null +++ b/src/components/atoms/modal/modal.module.scss @@ -0,0 +1,66 @@ +@use "../../../styles/abstracts/functions" as fun; +@use "../../../styles/abstracts/mixins" as mix; + +.modal { + position: relative; + box-shadow: + fun.convert-px(0.2) fun.convert-px(0.2) fun.convert-px(0.3) 0 + var(--color-shadow), + fun.convert-px(1.5) fun.convert-px(1.5) fun.convert-px(2.5) + fun.convert-px(-0.3) var(--color-shadow), + fun.convert-px(4.7) fun.convert-px(4.7) fun.convert-px(8) fun.convert-px(-1) + var(--color-shadow); + + &--primary { + padding: clamp(var(--spacing-xs), 2.5vw, var(--spacing-md)); + background: var(--color-bg-secondary); + border: fun.convert-px(3) solid; + border-image: radial-gradient( + ellipse at top, + var(--color-primary-lighter) 20%, + var(--color-primary) 100% + ) + 1; + + .title { + margin-bottom: var(--spacing-2xs); + } + + @include mix.media("screen") { + @include mix.dimensions(null, "sm") { + border-left: none; + border-right: none; + } + } + } + + &--secondary { + padding: clamp(var(--spacing-xs), 2.2vw, var(--spacing-sm)); + background: var(--color-bg); + border: fun.convert-px(2) solid var(--color-primary-dark); + border-radius: fun.convert-px(3); + + .title { + padding-inline: var(--spacing-xs); + background: var(--color-bg); + border: fun.convert-px(1) solid var(--color-primary-dark); + box-shadow: fun.convert-px(1) fun.convert-px(1) 0 0 var(--color-shadow); + color: var(--color-primary-darker); + font-variant: small-caps; + + > * { + margin-block: 0; + } + } + } + + &--secondary#{&}--has-title { + --title-height: #{fun.convert-px(40)}; + + .title { + width: fit-content; + height: var(--title-height); + margin: calc(var(--title-height) * -1) auto var(--spacing-xs); + } + } +} diff --git a/src/components/atoms/modal/modal.stories.tsx b/src/components/atoms/modal/modal.stories.tsx new file mode 100644 index 0000000..d0c2f0b --- /dev/null +++ b/src/components/atoms/modal/modal.stories.tsx @@ -0,0 +1,59 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Modal } from './modal'; +import { Heading } from '../headings'; + +/** + * Switch - Storybook Meta + */ +export default { + title: 'Atoms/Modals', + component: Modal, + args: {}, + argTypes: {}, +} as ComponentMeta<typeof Modal>; + +const Template: ComponentStory<typeof Modal> = (args) => <Modal {...args} />; + +/** + * Modal Stories - Primary + */ +export const Primary = Template.bind({}); +Primary.args = { + children: + 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.', +}; + +/** + * Modal Stories - Primary With Heading + */ +export const PrimaryWithHeading = Template.bind({}); +PrimaryWithHeading.args = { + children: + 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.', + heading: <Heading level={3}>Aut provident eum</Heading>, +}; + +/** + * Modal Stories - Secondary + */ +export const Secondary = Template.bind({}); +Secondary.args = { + children: + 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.', + kind: 'secondary', +}; + +/** + * Modal Stories - Secondary with heading + */ +export const SecondaryWithHeading = Template.bind({}); +SecondaryWithHeading.args = { + children: + 'Inventore natus dignissimos aut illum modi asperiores. Et voluptatibus delectus.', + heading: ( + <Heading isFake level={4}> + Aut provident eum + </Heading> + ), + kind: 'secondary', +}; diff --git a/src/components/atoms/modal/modal.test.tsx b/src/components/atoms/modal/modal.test.tsx new file mode 100644 index 0000000..5f32d02 --- /dev/null +++ b/src/components/atoms/modal/modal.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '../../../../tests/utils'; +import { Heading } from '../headings'; +import { Modal } from './modal'; + +const title = 'A custom title'; +const children = + 'Labore ullam delectus sit modi quam dolores. Ratione id sint aliquid facilis ipsum. Unde necessitatibus provident minus.'; + +describe('Modal', () => { + it('renders a title', () => { + const level = 2; + + render( + <Modal heading={<Heading level={level}>{title}</Heading>}> + {children} + </Modal> + ); + expect(screen.getByRole('heading', { level })).toHaveTextContent(title); + }); + + it('renders the modal body', () => { + render(<Modal>{children}</Modal>); + expect(screen.getByText(children)).toBeInTheDocument(); + }); +}); diff --git a/src/components/atoms/modal/modal.tsx b/src/components/atoms/modal/modal.tsx new file mode 100644 index 0000000..78b4f6e --- /dev/null +++ b/src/components/atoms/modal/modal.tsx @@ -0,0 +1,49 @@ +import { + ForwardRefRenderFunction, + HTMLAttributes, + ReactElement, + ReactNode, + forwardRef, +} from 'react'; +import { HeadingProps } from '../headings'; +import styles from './modal.module.scss'; + +export type ModalProps = HTMLAttributes<HTMLDivElement> & { + /** + * The modal body. + */ + children: ReactNode; + /** + * The modal title. + */ + heading?: ReactElement<HeadingProps>; + /** + * The modal kind. + * + * @default 'primary' + */ + kind?: 'primary' | 'secondary'; +}; + +const ModalWithRef: ForwardRefRenderFunction<HTMLDivElement, ModalProps> = ( + { children, className = '', heading, kind = 'primary', ...props }, + ref +) => { + const headingModifier = heading ? 'modal--has-title' : ''; + const kindModifier = `modal--${kind}`; + const modalClass = `${styles.modal} ${styles[headingModifier]} ${styles[kindModifier]} ${className}`; + + return ( + <div {...props} className={modalClass} ref={ref}> + {heading ? <div className={styles.title}>{heading}</div> : null} + {children} + </div> + ); +}; + +/** + * Modal component + * + * Render a modal component. + */ +export const Modal = forwardRef(ModalWithRef); |
