diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-04-08 22:36:24 +0200 | 
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-04-08 23:31:58 +0200 | 
| commit | 0b3146f7278929c4d1b33dd8f94f34e351e5e5a9 (patch) | |
| tree | 6a784b197a283a7da07c2e1df80a29fee8b3790a /src/components | |
| parent | 61278678ea8a8febee0574cd0f6006492d7b15cb (diff) | |
chore: add a Settings modal component
Diffstat (limited to 'src/components')
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; | 
