diff options
32 files changed, 761 insertions, 120 deletions
diff --git a/src/components/atoms/forms/field.stories.tsx b/src/components/atoms/forms/field.stories.tsx index 02681e7..ec81922 100644 --- a/src/components/atoms/forms/field.stories.tsx +++ b/src/components/atoms/forms/field.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; import FieldComponent from './field'; export default { @@ -7,11 +8,35 @@ export default { args: { disabled: false, required: false, - setValue: () => null, type: 'text', - value: '', }, 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, + }, + }, disabled: { control: { type: 'boolean', @@ -148,7 +173,7 @@ export default { }, value: { control: { - type: 'text', + type: null, }, description: 'Field value.', type: { @@ -159,14 +184,18 @@ export default { }, } as ComponentMeta<typeof FieldComponent>; -const Template: ComponentStory<typeof FieldComponent> = (args) => ( - <FieldComponent {...args} /> -); +const Template: ComponentStory<typeof FieldComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [value, setValue] = useState<string>(''); + + return <FieldComponent value={value} setValue={setValue} {...args} />; +}; export const Field = Template.bind({}); Field.args = { id: 'field-storybook', name: 'field-storybook', - setValue: () => null, - value: '', }; diff --git a/src/components/atoms/forms/field.tsx b/src/components/atoms/forms/field.tsx index 513d2ba..2e75d0f 100644 --- a/src/components/atoms/forms/field.tsx +++ b/src/components/atoms/forms/field.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, FC, SetStateAction } from 'react'; +import { ChangeEvent, SetStateAction, VFC } from 'react'; import styles from './forms.module.scss'; export type FieldType = @@ -14,6 +14,14 @@ export type FieldType = 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; @@ -64,7 +72,12 @@ export type FieldProps = { * * Render either an input or a textarea. */ -const Field: FC<FieldProps> = ({ setValue, type, ...props }) => { +const Field: VFC<FieldProps> = ({ + className = '', + setValue, + type, + ...props +}) => { /** * Update select value when an option is selected. * @param e - The option change event. @@ -78,14 +91,14 @@ const Field: FC<FieldProps> = ({ setValue, type, ...props }) => { return type === 'textarea' ? ( <textarea onChange={updateValue} - className={`${styles.field} ${styles['field--textarea']}`} + className={`${styles.field} ${styles['field--textarea']} ${className}`} {...props} /> ) : ( <input type={type} onChange={updateValue} - className={styles.field} + className={`${styles.field} ${className}`} {...props} /> ); diff --git a/src/components/atoms/forms/form.test.tsx b/src/components/atoms/forms/form.test.tsx new file mode 100644 index 0000000..9cd3c58 --- /dev/null +++ b/src/components/atoms/forms/form.test.tsx @@ -0,0 +1,9 @@ +import { render, screen } from '@test-utils'; +import Form from './form'; + +describe('Form', () => { + it('renders a form', () => { + render(<Form aria-label="Jest form" onSubmit={() => null}></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 new file mode 100644 index 0000000..8e80930 --- /dev/null +++ b/src/components/atoms/forms/form.tsx @@ -0,0 +1,73 @@ +import { Children, FC, FormEvent, Fragment } from 'react'; +import styles from './forms.module.scss'; + +export type FormProps = { + /** + * An accessible name. + */ + 'aria-label'?: string; + /** + * One or more ids that refers to the form name. + */ + 'aria-labelledby'?: string; + /** + * Set additional classnames to the form wrapper. + */ + className?: string; + /** + * Wrap each items with a div. Default: true. + */ + grouped?: boolean; + /** + * A callback function to execute on submit. + */ + onSubmit: () => void; +}; + +/** + * Form component. + * + * Render children wrapped in a form element. + */ +const Form: FC<FormProps> = ({ + children, + className = '', + grouped = true, + 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}> + {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 onSubmit={handleSubmit} className={className} {...props}> + {getFormItems()} + </form> + ); +}; + +export default Form; diff --git a/src/components/atoms/forms/forms.module.scss b/src/components/atoms/forms/forms.module.scss index 689a318..279c185 100644 --- a/src/components/atoms/forms/forms.module.scss +++ b/src/components/atoms/forms/forms.module.scss @@ -1,7 +1,12 @@ @use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.item { + margin: var(--spacing-xs) 0; + max-width: 45ch; +} .field { - width: 100%; padding: var(--spacing-2xs) var(--spacing-xs); background: var(--color-bg-tertiary); border: fun.convert-px(2) solid var(--color-border); @@ -10,6 +15,10 @@ &--select { cursor: pointer; + + @include mix.pointer("fine") { + padding: fun.convert-px(3) var(--spacing-xs); + } } &--textarea { @@ -41,15 +50,3 @@ } } } - -.label { - display: block; - color: var(--color-primary-darker); - font-size: var(--font-size-sm); - font-variant: small-caps; - font-weight: 600; -} - -.required { - color: var(--color-secondary); -} diff --git a/src/components/atoms/forms/label.module.scss b/src/components/atoms/forms/label.module.scss new file mode 100644 index 0000000..f900925 --- /dev/null +++ b/src/components/atoms/forms/label.module.scss @@ -0,0 +1,17 @@ +.label { + color: var(--color-primary-darker); + font-weight: 600; + + &--small { + font-size: var(--font-size-sm); + font-variant: small-caps; + } + + &--medium { + font-size: var(--font-size-md); + } +} + +.required { + color: var(--color-secondary); +} diff --git a/src/components/atoms/forms/label.stories.tsx b/src/components/atoms/forms/label.stories.tsx index 06e8eb9..463e8ac 100644 --- a/src/components/atoms/forms/label.stories.tsx +++ b/src/components/atoms/forms/label.stories.tsx @@ -4,7 +4,24 @@ import LabelComponent from './label'; export default { title: 'Atoms/Forms', component: LabelComponent, + args: { + required: false, + size: 'small', + }, argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, children: { control: { type: 'text', @@ -32,22 +49,37 @@ export default { description: 'Set to true if the field is required.', table: { category: 'Options', + defaultValue: { summary: false }, }, type: { name: 'boolean', required: false, }, }, + size: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + defaultValue: { summary: 'small' }, + }, + type: { + name: 'string', + required: false, + }, + }, }, } as ComponentMeta<typeof LabelComponent>; -const Template: ComponentStory<typeof LabelComponent> = (args) => { - const { children, ...props } = args; - return <LabelComponent {...props}>{children}</LabelComponent>; -}; +const Template: ComponentStory<typeof LabelComponent> = ({ + children, + ...args +}) => <LabelComponent {...args}>{children}</LabelComponent>; export const Label = Template.bind({}); Label.args = { children: 'A label', - htmlFor: 'a-field-id', }; diff --git a/src/components/atoms/forms/label.test.tsx b/src/components/atoms/forms/label.test.tsx index fcf1731..14257c3 100644 --- a/src/components/atoms/forms/label.test.tsx +++ b/src/components/atoms/forms/label.test.tsx @@ -3,7 +3,7 @@ import Label from './label'; describe('Label', () => { it('renders a field label', () => { - render(<Label htmlFor="a-field-id">A label</Label>); + render(<Label>A label</Label>); expect(screen.getByText('A label')).toBeDefined(); }); }); diff --git a/src/components/atoms/forms/label.tsx b/src/components/atoms/forms/label.tsx index 860cd73..8d57ee2 100644 --- a/src/components/atoms/forms/label.tsx +++ b/src/components/atoms/forms/label.tsx @@ -1,9 +1,23 @@ import { FC } from 'react'; -import styles from './forms.module.scss'; +import styles from './label.module.scss'; -type LabelProps = { - htmlFor: string; +export type LabelProps = { + /** + * Add classnames to the label. + */ + className?: string; + /** + * The field id. + */ + htmlFor?: string; + /** + * Is the field required? Default: false. + */ required?: boolean; + /** + * The label size. Default: small. + */ + size?: 'medium' | 'small'; }; /** @@ -11,9 +25,17 @@ type LabelProps = { * * Render a HTML label element. */ -const Label: FC<LabelProps> = ({ children, required = false, ...props }) => { +const Label: FC<LabelProps> = ({ + children, + className = '', + required = false, + size = 'small', + ...props +}) => { + const sizeClass = styles[`label--${size}`]; + return ( - <label className={styles.label} {...props}> + <label className={`${styles.label} ${sizeClass} ${className}`} {...props}> {children} {required && <span className={styles.required}> *</span>} </label> diff --git a/src/components/atoms/forms/select.stories.tsx b/src/components/atoms/forms/select.stories.tsx index c7bb253..c2fb8c6 100644 --- a/src/components/atoms/forms/select.stories.tsx +++ b/src/components/atoms/forms/select.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; import SelectComponent from './select'; const selectOptions = [ @@ -10,14 +11,31 @@ const selectOptions = [ export default { title: 'Atoms/Forms', component: SelectComponent, + args: { + disabled: false, + required: false, + }, argTypes: { - classes: { + 'aria-labelledby': { control: { type: 'text', }, - description: 'Set additional classes', + description: 'One or more ids that refers to the select field name.', table: { - category: 'Options', + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Add classnames to the select field.', + table: { + category: 'Styles', }, type: { name: 'string', @@ -59,9 +77,6 @@ export default { }, }, options: { - control: { - type: null, - }, description: 'Select options.', type: { name: 'array', @@ -100,7 +115,7 @@ export default { }, value: { control: { - type: 'text', + type: null, }, description: 'Field value.', type: { @@ -111,13 +126,20 @@ export default { }, } as ComponentMeta<typeof SelectComponent>; -const Template: ComponentStory<typeof SelectComponent> = (args) => ( - <SelectComponent {...args} /> -); +const Template: ComponentStory<typeof SelectComponent> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>(value); + + return <SelectComponent value={selected} setValue={setSelected} {...args} />; +}; export const Select = Template.bind({}); Select.args = { + id: 'storybook-select', + name: 'storybook-select', options: selectOptions, - setValue: () => null, value: 'option2', }; diff --git a/src/components/atoms/forms/select.tsx b/src/components/atoms/forms/select.tsx index a42dbda..25e86e0 100644 --- a/src/components/atoms/forms/select.tsx +++ b/src/components/atoms/forms/select.tsx @@ -1,17 +1,30 @@ -import { ChangeEvent, FC, SetStateAction } from 'react'; +import { ChangeEvent, SetStateAction, VFC } 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 = { /** - * Set additional classes. + * One or more ids that refers to the select field name. + */ + 'aria-labelledby'?: string; + /** + * Add classnames to the select field. */ - classes?: string; + className?: string; /** * Field state. Either enabled (false) or disabled (true). */ @@ -47,8 +60,8 @@ export type SelectProps = { * * Render a HTML select element. */ -const Select: FC<SelectProps> = ({ - classes = '', +const Select: VFC<SelectProps> = ({ + className = '', options, setValue, ...props @@ -74,7 +87,7 @@ const Select: FC<SelectProps> = ({ return ( <select - className={`${styles.field} ${styles['field--select']} ${classes}`} + className={`${styles.field} ${styles['field--select']} ${className}`} onChange={updateValue} {...props} > diff --git a/src/components/atoms/forms/toggle.module.scss b/src/components/atoms/forms/toggle.module.scss index 24b867e..2e8a49f 100644 --- a/src/components/atoms/forms/toggle.module.scss +++ b/src/components/atoms/forms/toggle.module.scss @@ -10,7 +10,7 @@ } .title { - margin-right: auto; + margin-right: var(--spacing-2xs); } .toggle { diff --git a/src/components/atoms/forms/toggle.tsx b/src/components/atoms/forms/toggle.tsx index 7ef40ed..c3bc09d 100644 --- a/src/components/atoms/forms/toggle.tsx +++ b/src/components/atoms/forms/toggle.tsx @@ -27,6 +27,10 @@ export type ToggleProps = { */ label: string; /** + * Set additional classnames to the label. + */ + labelClassName?: string; + /** * The label size. */ labelSize?: LabelProps['size']; @@ -53,6 +57,7 @@ const Toggle: VFC<ToggleProps> = ({ choices, id, label, + labelClassName = '', labelSize, name, setValue, @@ -69,7 +74,7 @@ const Toggle: VFC<ToggleProps> = ({ className={styles.checkbox} /> <Label size={labelSize} htmlFor={id} className={styles.label}> - <span className={styles.title}>{label}</span> + <span className={`${styles.title} ${labelClassName}`}>{label}</span> {choices.left} <span className={styles.toggle}></span> {choices.right} diff --git a/src/components/molecules/forms/labelled-field.module.scss b/src/components/molecules/forms/labelled-field.module.scss new file mode 100644 index 0000000..64ef3d0 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.module.scss @@ -0,0 +1,9 @@ +.label { + &--left { + margin-right: var(--spacing-2xs); + } + + &--top { + display: block; + } +} diff --git a/src/components/molecules/forms/labelled-field.stories.tsx b/src/components/molecules/forms/labelled-field.stories.tsx index eb7f8b5..b77d71e 100644 --- a/src/components/molecules/forms/labelled-field.stories.tsx +++ b/src/components/molecules/forms/labelled-field.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; import LabelledFieldComponent from './labelled-field'; export default { @@ -6,6 +7,7 @@ export default { component: LabelledFieldComponent, args: { disabled: false, + labelPosition: 'top', required: false, }, argTypes: { @@ -43,6 +45,21 @@ export default { required: true, }, }, + labelPosition: { + control: { + type: 'select', + }, + description: 'The label position.', + options: ['left', 'top'], + table: { + category: 'Options', + defaultValue: { summary: 'top' }, + }, + type: { + name: 'string', + required: false, + }, + }, max: { control: { type: 'number', @@ -155,7 +172,7 @@ export default { }, value: { control: { - type: 'text', + type: null, }, description: 'Field value.', type: { @@ -166,15 +183,19 @@ export default { }, } as ComponentMeta<typeof LabelledFieldComponent>; -const Template: ComponentStory<typeof LabelledFieldComponent> = (args) => ( - <LabelledFieldComponent {...args} /> -); +const Template: ComponentStory<typeof LabelledFieldComponent> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [value, setValue] = useState<string>(''); + + return <LabelledFieldComponent value={value} setValue={setValue} {...args} />; +}; export const LabelledField = Template.bind({}); LabelledField.args = { id: 'labelled-field-storybook', label: 'Labelled field', name: 'labelled-field-storybook', - setValue: () => null, - value: '', }; diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx index 7f81e23..08d0126 100644 --- a/src/components/molecules/forms/labelled-field.tsx +++ b/src/components/molecules/forms/labelled-field.tsx @@ -1,20 +1,46 @@ import Field, { type FieldProps } from '@components/atoms/forms/field'; import Label from '@components/atoms/forms/label'; -import { FC } from 'react'; +import { VFC } from 'react'; +import styles from './labelled-field.module.scss'; -type LabelledFieldProps = FieldProps & { +export type LabelledFieldProps = FieldProps & { + /** + * Visually hide the field label. Default: false. + */ + hideLabel?: boolean; + /** + * The field label. + */ label: string; + /** + * The label position. Default: top. + */ + labelPosition?: 'left' | 'top'; }; -const LabelledField: FC<LabelledFieldProps> = ({ +/** + * LabelledField component + * + * Render a field tied to a label. + */ +const LabelledField: VFC<LabelledFieldProps> = ({ + hideLabel = false, id, label, + labelPosition = 'top', required, ...props }) => { + const positionModifier = `label--${labelPosition}`; + const visibilityClass = hideLabel ? 'screen-reader-text' : ''; + return ( <> - <Label htmlFor={id} required={required}> + <Label + htmlFor={id} + required={required} + className={`${visibilityClass} ${styles[positionModifier]}`} + > {label} </Label> <Field id={id} required={required} {...props} /> diff --git a/src/components/molecules/forms/labelled-select.module.scss b/src/components/molecules/forms/labelled-select.module.scss new file mode 100644 index 0000000..64ef3d0 --- /dev/null +++ b/src/components/molecules/forms/labelled-select.module.scss @@ -0,0 +1,9 @@ +.label { + &--left { + margin-right: var(--spacing-2xs); + } + + &--top { + display: block; + } +} diff --git a/src/components/molecules/forms/labelled-select.stories.tsx b/src/components/molecules/forms/labelled-select.stories.tsx index 0966e13..0c569f5 100644 --- a/src/components/molecules/forms/labelled-select.stories.tsx +++ b/src/components/molecules/forms/labelled-select.stories.tsx @@ -1,4 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; import LabelledSelectComponent from './labelled-select'; const selectOptions = [ @@ -12,6 +13,7 @@ export default { component: LabelledSelectComponent, args: { disabled: false, + labelPosition: 'top', required: false, }, argTypes: { @@ -49,6 +51,48 @@ export default { required: true, }, }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + labelPosition: { + control: { + type: 'select', + }, + description: 'The label position.', + options: ['left', 'top'], + table: { + category: 'Options', + defaultValue: { summary: 'top' }, + }, + type: { + name: 'string', + required: false, + }, + }, + labelSize: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, name: { control: { type: 'text', @@ -86,6 +130,19 @@ export default { required: false, }, }, + selectClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the select field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, setValue: { control: { type: null, @@ -101,7 +158,7 @@ export default { }, value: { control: { - type: 'text', + type: null, }, description: 'Field value.', type: { @@ -112,9 +169,21 @@ export default { }, } as ComponentMeta<typeof LabelledSelectComponent>; -const Template: ComponentStory<typeof LabelledSelectComponent> = (args) => ( - <LabelledSelectComponent {...args} /> -); +const Template: ComponentStory<typeof LabelledSelectComponent> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>(value); + + return ( + <LabelledSelectComponent + value={selected} + setValue={setSelected} + {...args} + /> + ); +}; export const LabelledSelect = Template.bind({}); LabelledSelect.args = { @@ -122,6 +191,5 @@ LabelledSelect.args = { label: 'Labelled select', name: 'labelled-select-storybook', options: selectOptions, - setValue: () => null, - value: '', + value: 'option1', }; diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx index 442e91a..7d4237a 100644 --- a/src/components/molecules/forms/labelled-select.tsx +++ b/src/components/molecules/forms/labelled-select.tsx @@ -1,23 +1,62 @@ -import Label from '@components/atoms/forms/label'; +import Label, { LabelProps } from '@components/atoms/forms/label'; import Select, { type SelectProps } from '@components/atoms/forms/select'; -import { FC } from 'react'; +import { VFC } from 'react'; +import styles from './labelled-select.module.scss'; -type LabelledSelectProps = SelectProps & { +export type LabelledSelectProps = Omit< + SelectProps, + 'aria-labelledby' | 'className' +> & { + /** + * The field label. + */ label: string; + /** + * Set additional classnames to the label. + */ + labelClassName?: string; + /** + * The label position. Default: top. + */ + labelPosition?: 'left' | 'top'; + /** + * The label size. + */ + labelSize?: LabelProps['size']; + /** + * Set additional classnames to the select field. + */ + selectClassName?: string; }; -const LabelledSelect: FC<LabelledSelectProps> = ({ +const LabelledSelect: VFC<LabelledSelectProps> = ({ id, label, + labelClassName = '', + labelPosition = 'top', + labelSize, required, + selectClassName = '', ...props }) => { + const positionModifier = `label--${labelPosition}`; + return ( <> - <Label htmlFor={id} required={required}> + <Label + htmlFor={id} + required={required} + size={labelSize} + className={`${styles[positionModifier]} ${labelClassName}`} + > {label} </Label> - <Select id={id} required={required} {...props} /> + <Select + id={id} + required={required} + {...props} + className={selectClassName} + /> </> ); }; diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx index 4fc199a..dc4d2a9 100644 --- a/src/components/molecules/forms/motion-toggle.stories.tsx +++ b/src/components/molecules/forms/motion-toggle.stories.tsx @@ -5,6 +5,18 @@ import MotionToggleComponent from './motion-toggle'; export default { title: 'Molecules/Forms', component: MotionToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The reduce motion value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, } as ComponentMeta<typeof MotionToggleComponent>; const Template: ComponentStory<typeof MotionToggleComponent> = (args) => ( @@ -14,3 +26,6 @@ const Template: ComponentStory<typeof MotionToggleComponent> = (args) => ( ); export const MotionToggle = Template.bind({}); +MotionToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx index 77291ed..d4f7d11 100644 --- a/src/components/molecules/forms/motion-toggle.tsx +++ b/src/components/molecules/forms/motion-toggle.tsx @@ -2,17 +2,17 @@ import Toggle, { ToggleChoices, ToggleProps, } from '@components/atoms/forms/toggle'; -import { FC, useState } from 'react'; +import { useState, VFC } from 'react'; import { useIntl } from 'react-intl'; -export type MotionToggleProps = Pick<ToggleProps, 'value'>; +export type MotionToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>; /** * MotionToggle component * * Render a Toggle component to set reduce motion. */ -const MotionToggle: FC<MotionToggleProps> = ({ value }) => { +const MotionToggle: VFC<MotionToggleProps> = ({ value, ...props }) => { const intl = useIntl(); const [isDeactivated, setIsDeactivated] = useState<boolean>(value); const reduceMotionLabel = intl.formatMessage({ @@ -40,9 +40,11 @@ const MotionToggle: FC<MotionToggleProps> = ({ value }) => { id="reduce-motion-settings" name="reduce-motion-settings" label={reduceMotionLabel} + labelSize="medium" choices={reduceMotionChoices} value={isDeactivated} setValue={setIsDeactivated} + {...props} /> ); }; diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx index fee54ef..dc9090b 100644 --- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx @@ -5,6 +5,18 @@ import PrismThemeToggleComponent from './prism-theme-toggle'; export default { title: 'Molecules/Forms', component: PrismThemeToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The prism theme value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, } as ComponentMeta<typeof PrismThemeToggleComponent>; const Template: ComponentStory<typeof PrismThemeToggleComponent> = (args) => ( @@ -14,3 +26,6 @@ const Template: ComponentStory<typeof PrismThemeToggleComponent> = (args) => ( ); export const PrismThemeToggle = Template.bind({}); +PrismThemeToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx index cedb71a..81a211b 100644 --- a/src/components/molecules/forms/prism-theme-toggle.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.tsx @@ -4,17 +4,20 @@ import Toggle, { } from '@components/atoms/forms/toggle'; import Moon from '@components/atoms/icons/moon'; import Sun from '@components/atoms/icons/sun'; -import { FC, useState } from 'react'; +import { useState, VFC } from 'react'; import { useIntl } from 'react-intl'; -export type PrismThemeToggleProps = Pick<ToggleProps, 'value'>; +export type PrismThemeToggleProps = Pick< + ToggleProps, + 'labelClassName' | 'value' +>; /** * PrismThemeToggle component * * Render a Toggle component to set code blocks theme. */ -const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value }) => { +const PrismThemeToggle: VFC<PrismThemeToggleProps> = ({ value, ...props }) => { const intl = useIntl(); const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value); const themeLabel = intl.formatMessage({ @@ -42,9 +45,11 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ value }) => { id="prism-theme-settings" name="prism-theme-settings" label={themeLabel} + labelSize="medium" choices={themeChoices} value={isDarkTheme} setValue={setIsDarkTheme} + {...props} /> ); }; diff --git a/src/components/molecules/forms/select-with-tooltip.module.scss b/src/components/molecules/forms/select-with-tooltip.module.scss index 1f91f74..bfadece 100644 --- a/src/components/molecules/forms/select-with-tooltip.module.scss +++ b/src/components/molecules/forms/select-with-tooltip.module.scss @@ -5,14 +5,9 @@ display: flex; flex-flow: row wrap; align-items: center; - gap: var(--spacing-xs); position: relative; } -.label { - margin-right: auto; -} - .select { width: auto; @@ -22,6 +17,8 @@ } .btn { + margin-left: var(--spacing-xs); + &--activated { background: var(--color-primary); @@ -34,8 +31,7 @@ .tooltip { position: absolute; top: calc(100% + var(--spacing-xs)); - right: 0; - transform-origin: top right; + transform-origin: top; transition: all 0.75s ease-in-out 0s; &--hidden { diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx index d2d36fa..c63e9b8 100644 --- a/src/components/molecules/forms/select-with-tooltip.stories.tsx +++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx @@ -17,11 +17,25 @@ export default { required: true, }, }, - title: { + disabled: { + control: { + type: 'boolean', + }, + description: 'Field state: either enabled or disabled.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { control: { type: 'text', }, - description: 'The tooltip title', + description: 'Field id.', type: { name: 'string', required: true, @@ -37,28 +51,31 @@ export default { required: true, }, }, - disabled: { + labelClassName: { control: { - type: 'boolean', + type: 'text', }, - description: 'Field state: either enabled or disabled.', + description: 'Set additional classnames to the label.', table: { - category: 'Options', - defaultValue: { summary: false }, + category: 'Styles', }, type: { - name: 'boolean', + name: 'string', required: false, }, }, - id: { + labelSize: { control: { - type: 'text', + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', }, - description: 'Field id.', type: { name: 'string', - required: true, + required: false, }, }, name: { @@ -98,6 +115,19 @@ export default { required: false, }, }, + selectClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the select field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, setValue: { control: { type: null, @@ -111,6 +141,29 @@ export default { required: true, }, }, + title: { + control: { + type: 'text', + }, + description: 'The tooltip title', + type: { + name: 'string', + required: true, + }, + }, + tooltipClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the tooltip.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, value: { control: { type: 'text', diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx index 5e48d62..f537e1e 100644 --- a/src/components/molecules/forms/select-with-tooltip.tsx +++ b/src/components/molecules/forms/select-with-tooltip.tsx @@ -1,19 +1,22 @@ -import Select, { SelectProps } from '@components/atoms/forms/select'; -import { FC, useState } from 'react'; +import { useState, VFC } from 'react'; import HelpButton from '../buttons/help-button'; -import Tooltip, { TooltipProps } from '../modals/tooltip'; +import Tooltip, { type TooltipProps } from '../modals/tooltip'; +import LabelledSelect, { type LabelledSelectProps } from './labelled-select'; import styles from './select-with-tooltip.module.scss'; -export type SelectWithTooltipProps = SelectProps & +export type SelectWithTooltipProps = Omit< + LabelledSelectProps, + 'labelPosition' +> & Pick<TooltipProps, 'title' | 'content'> & { /** * The select label. */ label: string; /** - * Set additional classes to the tooltip wrapper. + * Set additional classnames to the tooltip wrapper. */ - tooltipClasses?: string; + tooltipClassName?: string; }; /** @@ -21,12 +24,11 @@ export type SelectWithTooltipProps = SelectProps & * * Render a select with a button to display a tooltip about options. */ -const SelectWithTooltip: FC<SelectWithTooltipProps> = ({ +const SelectWithTooltip: VFC<SelectWithTooltipProps> = ({ title, content, id, - label, - tooltipClasses = '', + tooltipClassName = '', ...props }) => { const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false); @@ -37,19 +39,21 @@ const SelectWithTooltip: FC<SelectWithTooltipProps> = ({ return ( <div className={styles.wrapper}> - <label htmlFor={id} className={styles.label}> - {label} - </label> - <Select id={id} {...props} classes={styles.select} /> + <LabelledSelect + labelPosition="left" + id={id} + labelClassName={styles.label} + {...props} + /> <HelpButton onClick={() => setIsTooltipOpened(!isTooltipOpened)} - classes={buttonModifier} + className={`${styles.btn} ${buttonModifier}`} /> <Tooltip title={title} content={content} icon="?" - classes={`${styles.tooltip} ${tooltipModifier} ${tooltipClasses}`} + className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`} /> </div> ); diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx index 5970afd..a9bcf73 100644 --- a/src/components/molecules/forms/theme-toggle.stories.tsx +++ b/src/components/molecules/forms/theme-toggle.stories.tsx @@ -5,6 +5,18 @@ import ThemeToggleComponent from './theme-toggle'; export default { title: 'Molecules/Forms', component: ThemeToggleComponent, + argTypes: { + value: { + control: { + type: null, + }, + description: 'The theme value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, } as ComponentMeta<typeof ThemeToggleComponent>; const Template: ComponentStory<typeof ThemeToggleComponent> = (args) => ( @@ -14,3 +26,6 @@ const Template: ComponentStory<typeof ThemeToggleComponent> = (args) => ( ); export const ThemeToggle = Template.bind({}); +ThemeToggle.args = { + value: false, +}; diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx index c927151..6d54591 100644 --- a/src/components/molecules/forms/theme-toggle.tsx +++ b/src/components/molecules/forms/theme-toggle.tsx @@ -4,17 +4,17 @@ import Toggle, { } from '@components/atoms/forms/toggle'; import Moon from '@components/atoms/icons/moon'; import Sun from '@components/atoms/icons/sun'; -import { FC, useState } from 'react'; +import { useState, VFC } from 'react'; import { useIntl } from 'react-intl'; -export type ThemeToggleProps = Pick<ToggleProps, 'value'>; +export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName' | 'value'>; /** * ThemeToggle component * * Render a Toggle component to set theme. */ -const ThemeToggle: FC<ThemeToggleProps> = ({ value }) => { +const ThemeToggle: VFC<ThemeToggleProps> = ({ value, ...props }) => { const intl = useIntl(); const [isDarkTheme, setIsDarkTheme] = useState<boolean>(value); const themeLabel = intl.formatMessage({ @@ -42,9 +42,11 @@ const ThemeToggle: FC<ThemeToggleProps> = ({ value }) => { id="theme-settings" name="theme-settings" label={themeLabel} + labelSize="medium" choices={themeChoices} value={isDarkTheme} setValue={setIsDarkTheme} + {...props} /> ); }; diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss new file mode 100644 index 0000000..f17c9b3 --- /dev/null +++ b/src/components/organisms/modals/settings-modal.module.scss @@ -0,0 +1,14 @@ +.wrapper { + max-width: 30ch; + + .label { + margin-right: auto; + } +} + +.tooltip { + width: 120%; + top: calc(100% + var(--spacing-sm)); + right: -10%; + transform-origin: top right; +} diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx new file mode 100644 index 0000000..c19a6d7 --- /dev/null +++ b/src/components/organisms/modals/settings-modal.stories.tsx @@ -0,0 +1,31 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SettingsModal from './settings-modal'; + +export default { + title: 'Organisms/Modals', + component: SettingsModal, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the modal wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof SettingsModal>; + +const Template: ComponentStory<typeof SettingsModal> = (args) => ( + <IntlProvider locale="en"> + <SettingsModal {...args} /> + </IntlProvider> +); + +export const Settings = Template.bind({}); diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx new file mode 100644 index 0000000..44695d7 --- /dev/null +++ b/src/components/organisms/modals/settings-modal.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@test-utils'; +import SettingsModal from './settings-modal'; + +jest.mock('next/dynamic', () => () => 'dynamic-import'); + +describe('SettingsModal', () => { + it('renders a theme toggle setting', () => { + render(<SettingsModal />); + expect( + screen.getByRole('checkbox', { name: /^Theme:/i }) + ).toBeInTheDocument(); + }); + + it('renders a code blocks toggle setting', () => { + render(<SettingsModal />); + expect( + screen.getByRole('checkbox', { name: /^Code blocks:/i }) + ).toBeInTheDocument(); + }); + + it('renders a motion setting', () => { + render(<SettingsModal />); + expect( + screen.getByRole('checkbox', { name: /^Animations:/i }) + ).toBeInTheDocument(); + }); + + it('renders a Ackee setting', () => { + render(<SettingsModal />); + expect( + screen.getByRole('combobox', { name: /^Tracking:/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx new file mode 100644 index 0000000..0fac332 --- /dev/null +++ b/src/components/organisms/modals/settings-modal.tsx @@ -0,0 +1,51 @@ +import Form from '@components/atoms/forms/form'; +import AckeeSelect from '@components/molecules/forms/ackee-select'; +import MotionToggle from '@components/molecules/forms/motion-toggle'; +import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle'; +import ThemeToggle from '@components/molecules/forms/theme-toggle'; +import Modal from '@components/molecules/modals/modal'; +import { VFC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './settings-modal.module.scss'; + +export type SettingsModalProps = { + /** + * Set additional classnames to modal wrapper. + */ + className?: string; +}; + +/** + * SettingsModal component + * + * Render a modal with settings options. + */ +const SettingsModal: VFC<SettingsModalProps> = ({ className }) => { + const intl = useIntl(); + const title = intl.formatMessage({ + defaultMessage: 'Settings', + description: 'SettingsModal: title', + id: 'gPfT/K', + }); + + return ( + <Modal + title={title} + icon="cogs" + className={`${styles.wrapper} ${className}`} + > + <Form onSubmit={() => null}> + <ThemeToggle labelClassName={styles.label} value={false} /> + <PrismThemeToggle labelClassName={styles.label} value={false} /> + <MotionToggle labelClassName={styles.label} value={false} /> + <AckeeSelect + initialValue="full" + labelClassName={styles.label} + tooltipClassName={styles.tooltip} + /> + </Form> + </Modal> + ); +}; + +export default SettingsModal; |
