diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-24 19:35:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-05-24 19:35:12 +0200 |
| commit | c85ab5ad43ccf52881ee224672c41ec30021cf48 (patch) | |
| tree | 8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/molecules/forms | |
| parent | 52404177c07a2aab7fc894362fb3060dff2431a0 (diff) | |
| parent | 11b9de44a4b2f305a6a484187805e429b2767118 (diff) | |
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/molecules/forms')
33 files changed, 2175 insertions, 0 deletions
diff --git a/src/components/molecules/forms/ackee-select.module.scss b/src/components/molecules/forms/ackee-select.module.scss new file mode 100644 index 0000000..87cd9ee --- /dev/null +++ b/src/components/molecules/forms/ackee-select.module.scss @@ -0,0 +1,11 @@ +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + position: relative; +} + +.tooltip { + position: absolute; + bottom: -100%; +} diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx new file mode 100644 index 0000000..6b42b71 --- /dev/null +++ b/src/components/molecules/forms/ackee-select.stories.tsx @@ -0,0 +1,71 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import AckeeSelect from './ackee-select'; + +/** + * AckeeSelect - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Select', + component: AckeeSelect, + argTypes: { + initialValue: { + control: { + type: 'select', + }, + description: 'Initial selected option.', + options: ['full', 'partial'], + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + storageKey: { + control: { + type: 'text', + }, + description: 'Set Ackee settings local storage key.', + type: { + name: 'string', + required: true, + }, + }, + tooltipClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the tooltip wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof AckeeSelect>; + +const Template: ComponentStory<typeof AckeeSelect> = (args) => ( + <AckeeSelect {...args} /> +); + +/** + * Select Stories - Ackee select + */ +export const Ackee = Template.bind({}); +Ackee.args = { + initialValue: 'full', +}; diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx new file mode 100644 index 0000000..0089c06 --- /dev/null +++ b/src/components/molecules/forms/ackee-select.test.tsx @@ -0,0 +1,25 @@ +import user from '@testing-library/user-event'; +import { act, render, screen } from '@test-utils'; +import AckeeSelect from './ackee-select'; + +describe('Select', () => { + it('should correctly set default option', () => { + render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />); + expect(screen.getByRole('combobox')).toHaveValue('full'); + expect(screen.queryByRole('combobox')).not.toHaveValue('partial'); + }); + + it('should correctly change value when user choose another option', async () => { + render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />); + + await act(async () => { + await user.selectOptions( + screen.getByRole('combobox'), + screen.getByRole('option', { name: 'Partial' }) + ); + }); + + expect(screen.getByRole('combobox')).toHaveValue('partial'); + expect(screen.queryByRole('combobox')).not.toHaveValue('full'); + }); +}); diff --git a/src/components/molecules/forms/ackee-select.tsx b/src/components/molecules/forms/ackee-select.tsx new file mode 100644 index 0000000..34850fb --- /dev/null +++ b/src/components/molecules/forms/ackee-select.tsx @@ -0,0 +1,103 @@ +import { type SelectOptions } from '@components/atoms/forms/select'; +import useLocalStorage from '@utils/hooks/use-local-storage'; +import useUpdateAckeeOptions, { + type AckeeOptions, +} from '@utils/hooks/use-update-ackee-options'; +import { Dispatch, FC, SetStateAction } from 'react'; +import { useIntl } from 'react-intl'; +import SelectWithTooltip, { + type SelectWithTooltipProps, +} from './select-with-tooltip'; + +export type AckeeSelectProps = Pick< + SelectWithTooltipProps, + 'labelClassName' | 'tooltipClassName' +> & { + /** + * A default value for Ackee settings. + */ + initialValue: AckeeOptions; + /** + * The local storage key to save preference. + */ + storageKey: string; +}; + +/** + * AckeeSelect component + * + * Render a select to set Ackee settings. + */ +const AckeeSelect: FC<AckeeSelectProps> = ({ + initialValue, + storageKey, + ...props +}) => { + const intl = useIntl(); + const { value, setValue } = useLocalStorage<AckeeOptions>( + storageKey, + initialValue + ); + useUpdateAckeeOptions(value); + + const ackeeLabel = intl.formatMessage({ + defaultMessage: 'Tracking:', + description: 'AckeeSelect: select label', + id: '2pmylc', + }); + const tooltipTitle = intl.formatMessage({ + defaultMessage: 'Ackee tracking (analytics)', + description: 'AckeeSelect: tooltip title', + id: 'F1EQX3', + }); + const tooltipContent = [ + intl.formatMessage({ + defaultMessage: 'Partial includes only page url, views and duration.', + description: 'AckeeSelect: tooltip message', + id: 'skb4W5', + }), + intl.formatMessage({ + defaultMessage: + 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.', + description: 'AckeeSelect: tooltip message', + id: 'Ogccx6', + }), + ]; + const options: SelectOptions[] = [ + { + id: 'partial', + name: intl.formatMessage({ + defaultMessage: 'Partial', + description: 'AckeeSelect: partial option name', + id: 'e/8Kyj', + }), + value: 'partial', + }, + { + id: 'full', + name: intl.formatMessage({ + defaultMessage: 'Full', + description: 'AckeeSelect: full option name', + id: 'PzRpPw', + }), + value: 'full', + }, + ]; + + return ( + <SelectWithTooltip + id="ackee-settings" + name="ackee-settings" + label={ackeeLabel} + labelSize="medium" + options={options} + title={tooltipTitle} + content={tooltipContent} + value={value} + setValue={setValue as Dispatch<SetStateAction<string>>} + {...props} + /> + ); +}; + +export default AckeeSelect; diff --git a/src/components/molecules/forms/flipping-label.module.scss b/src/components/molecules/forms/flipping-label.module.scss new file mode 100644 index 0000000..e650ebe --- /dev/null +++ b/src/components/molecules/forms/flipping-label.module.scss @@ -0,0 +1,63 @@ +@use "@styles/abstracts/functions" as fun; + +.label { + display: block; + width: var(--btn-size, #{fun.convert-px(60)}); + height: var(--btn-size, #{fun.convert-px(60)}); +} + +.front, +.back { + display: flex; + place-content: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + right: 0; + backface-visibility: hidden; + transition: all 0.6s ease-in 0s; +} + +.front { + z-index: 20; +} + +.back { + z-index: 10; +} + +.wrapper { + --icon-size: 60%; + + display: flex; + place-content: center; + place-items: center; + width: 100%; + height: 100%; + position: relative; + transition: all 0.5s ease-in-out 0s; + transform-style: preserve-3d; + + &--active { + transform: rotateY(180deg); + + .front { + transform: scale(0.2); + } + + .back { + transform: scale(1) rotateY(180deg); + } + } + + &--inactive { + .front { + transform: scale(1); + } + + .back { + transform: scale(0.2) rotateY(180deg); + } + } +} diff --git a/src/components/molecules/forms/flipping-label.stories.tsx b/src/components/molecules/forms/flipping-label.stories.tsx new file mode 100644 index 0000000..b8d17ec --- /dev/null +++ b/src/components/molecules/forms/flipping-label.stories.tsx @@ -0,0 +1,96 @@ +import MagnifyingGlass from '@components/atoms/icons/magnifying-glass'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import FlippingLabel from './flipping-label'; + +export default { + title: 'Organisms/Forms/FlippingLabel', + component: FlippingLabel, + argTypes: { + 'aria-label': { + control: { + type: 'text', + }, + description: 'An accessible name for the label.', + table: { + category: 'Accessibility', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + control: { + type: null, + }, + description: 'An icon for the label front face.', + type: { + name: 'function', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + htmlFor: { + control: { + type: null, + }, + description: 'Bind the label to a field by id.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + isActive: { + control: { + type: 'boolean', + }, + description: + 'Which side of the label should be displayed? True for the close icon.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof FlippingLabel>; + +const Template: ComponentStory<typeof FlippingLabel> = ({ + isActive, + ...args +}) => { + const [active, setActive] = useState<boolean>(isActive); + + return ( + <div onClick={() => setActive(!active)}> + <FlippingLabel isActive={active} {...args} /> + </div> + ); +}; + +export const Active = Template.bind({}); +Active.args = { + children: <MagnifyingGlass />, + isActive: true, +}; + +export const Inactive = Template.bind({}); +Inactive.args = { + children: <MagnifyingGlass />, + isActive: false, +}; diff --git a/src/components/molecules/forms/flipping-label.test.tsx b/src/components/molecules/forms/flipping-label.test.tsx new file mode 100644 index 0000000..9a7aa22 --- /dev/null +++ b/src/components/molecules/forms/flipping-label.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from '@test-utils'; +import FlippingLabel from './flipping-label'; + +describe('FlippingLabel', () => { + it('renders a label', () => { + const ariaLabel = 'vero quo inventore'; + render( + <FlippingLabel aria-label={ariaLabel} isActive={false}> + <>Test</> + </FlippingLabel> + ); + expect(screen.getByLabelText(ariaLabel)).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/forms/flipping-label.tsx b/src/components/molecules/forms/flipping-label.tsx new file mode 100644 index 0000000..c874369 --- /dev/null +++ b/src/components/molecules/forms/flipping-label.tsx @@ -0,0 +1,40 @@ +import Label, { LabelProps } from '@components/atoms/forms/label'; +import Close from '@components/atoms/icons/close'; +import { FC } from 'react'; +import styles from './flipping-label.module.scss'; + +export type FlippingLabelProps = Pick< + LabelProps, + 'aria-label' | 'className' | 'htmlFor' +> & { + /** + * The front icon. + */ + children: JSX.Element; + /** + * Which side of the label should be displayed? True for the close icon. + */ + isActive: boolean; +}; + +const FlippingLabel: FC<FlippingLabelProps> = ({ + children, + className = '', + isActive, + ...props +}) => { + const wrapperModifier = isActive ? 'wrapper--active' : 'wrapper--inactive'; + + return ( + <Label className={`${styles.label} ${className}`} {...props}> + <span className={`${styles.wrapper} ${styles[wrapperModifier]}`}> + <span className={styles.front}>{children}</span> + <span className={styles.back}> + <Close /> + </span> + </span> + </Label> + ); +}; + +export default FlippingLabel; 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 new file mode 100644 index 0000000..795e785 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.stories.tsx @@ -0,0 +1,293 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import LabelledField from './labelled-field'; + +/** + * LabelledField - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Field', + component: LabelledField, + args: { + disabled: false, + hideLabel: false, + labelPosition: 'top', + required: 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: 'Set additional classnames to the field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + 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: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + hideLabel: { + control: { + type: 'boolean', + }, + description: 'Visually hide the field label.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'Field label.', + type: { + name: 'string', + 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', + }, + description: 'Maximum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + min: { + control: { + type: 'number', + }, + description: 'Minimum value.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + placeholder: { + control: { + type: 'text', + }, + description: 'A placeholder value.', + table: { + category: 'Options', + }, + type: { + name: 'string', + 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.', + table: { + category: 'Options', + }, + type: { + name: 'number', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'Field type: input type or textarea.', + options: [ + 'datetime-local', + 'email', + 'number', + 'search', + 'tel', + 'text', + 'textarea', + 'time', + 'url', + ], + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LabelledField>; + +const Template: ComponentStory<typeof LabelledField> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [value, setValue] = useState<string>(''); + + return <LabelledField value={value} setValue={setValue} {...args} />; +}; + +/** + * Labelled Field Stories - Left + */ +export const Left = Template.bind({}); +Left.args = { + id: 'labelled-field-storybook', + label: 'Labelled field', + labelPosition: 'left', + name: 'labelled-field-storybook', +}; + +/** + * Labelled Field Stories - Top + */ +export const Top = Template.bind({}); +Top.args = { + id: 'labelled-field-storybook', + label: 'Labelled field', + labelPosition: 'top', + name: 'labelled-field-storybook', +}; + +/** + * Labelled Field Stories - Required + */ +export const Required = Template.bind({}); +Required.args = { + id: 'labelled-field-storybook', + label: 'Labelled field', + name: 'labelled-field-storybook', + required: true, +}; + +/** + * Labelled Field Stories - Hidden label + */ +export const HiddenLabel = Template.bind({}); +HiddenLabel.args = { + hideLabel: true, + id: 'labelled-field-storybook', + label: 'Labelled field', + name: 'labelled-field-storybook', +}; + +/** + * Labelled Field Stories - Disabled + */ +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, + id: 'labelled-field-storybook', + label: 'Labelled field', + name: 'labelled-field-storybook', +}; diff --git a/src/components/molecules/forms/labelled-field.test.tsx b/src/components/molecules/forms/labelled-field.test.tsx new file mode 100644 index 0000000..6fabe19 --- /dev/null +++ b/src/components/molecules/forms/labelled-field.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@test-utils'; +import LabelledField from './labelled-field'; + +describe('LabelledField', () => { + it('renders a labelled field', () => { + render( + <LabelledField + type="text" + id="jest-text-field" + name="jest-text-field" + label="Jest text field" + value="test" + setValue={() => null} + /> + ); + expect(screen.getByLabelText('Jest text field')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('test'); + }); +}); diff --git a/src/components/molecules/forms/labelled-field.tsx b/src/components/molecules/forms/labelled-field.tsx new file mode 100644 index 0000000..6a00a3e --- /dev/null +++ b/src/components/molecules/forms/labelled-field.tsx @@ -0,0 +1,50 @@ +import Field, { type FieldProps } from '@components/atoms/forms/field'; +import Label from '@components/atoms/forms/label'; +import { forwardRef, ForwardRefRenderFunction } from 'react'; +import styles from './labelled-field.module.scss'; + +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'; +}; + +/** + * LabelledField component + * + * Render a field tied to a label. + */ +const LabelledField: ForwardRefRenderFunction< + HTMLInputElement, + LabelledFieldProps +> = ( + { hideLabel = false, id, label, labelPosition = 'top', required, ...props }, + ref +) => { + const positionModifier = `label--${labelPosition}`; + const visibilityClass = hideLabel ? 'screen-reader-text' : ''; + + return ( + <> + <Label + htmlFor={id} + required={required} + className={`${visibilityClass} ${styles[positionModifier]}`} + > + {label} + </Label> + <Field id={id} ref={ref} required={required} {...props} /> + </> + ); +}; + +export default forwardRef(LabelledField); 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 new file mode 100644 index 0000000..d02732c --- /dev/null +++ b/src/components/molecules/forms/labelled-select.stories.tsx @@ -0,0 +1,236 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import LabelledSelect from './labelled-select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +/** + * LabelledSelect - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Select', + component: LabelledSelect, + args: { + disabled: false, + labelPosition: 'top', + required: false, + }, + argTypes: { + 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: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'Field label.', + type: { + name: 'string', + 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', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + options: { + control: { + type: null, + }, + description: 'Select options.', + type: { + name: 'array', + required: true, + value: { + name: 'string', + }, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + 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, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LabelledSelect>; + +const Template: ComponentStory<typeof LabelledSelect> = ({ + value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>(value); + + return <LabelledSelect value={selected} setValue={setSelected} {...args} />; +}; + +/** + * Labelled Select Stories - Left + */ +export const Left = Template.bind({}); +Left.args = { + id: 'labelled-select-storybook', + label: 'Labelled select', + labelPosition: 'left', + name: 'labelled-select-storybook', + options: selectOptions, + value: 'option1', +}; + +/** + * Labelled Select Stories - Top + */ +export const Top = Template.bind({}); +Top.args = { + id: 'labelled-select-storybook', + label: 'Labelled select', + labelPosition: 'top', + name: 'labelled-select-storybook', + options: selectOptions, + value: 'option1', +}; + +/** + * Labelled Select Stories - Disabled + */ +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, + id: 'labelled-select-storybook', + label: 'Labelled select', + name: 'labelled-select-storybook', + options: selectOptions, + value: 'option1', +}; + +/** + * Labelled Select Stories - Required + */ +export const Required = Template.bind({}); +Required.args = { + id: 'labelled-select-storybook', + label: 'Labelled select', + labelPosition: 'top', + name: 'labelled-select-storybook', + options: selectOptions, + required: true, + value: 'option1', +}; diff --git a/src/components/molecules/forms/labelled-select.test.tsx b/src/components/molecules/forms/labelled-select.test.tsx new file mode 100644 index 0000000..9a50d6e --- /dev/null +++ b/src/components/molecules/forms/labelled-select.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from '@test-utils'; +import LabelledSelect from './labelled-select'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +describe('LabelledSelect', () => { + it('renders a labelled select', () => { + render( + <LabelledSelect + id="jest-select-field" + name="jest-select-field" + label="Jest select field" + options={selectOptions} + value="option1" + setValue={() => null} + /> + ); + expect(screen.getByLabelText('Jest select field')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('option1'); + }); +}); diff --git a/src/components/molecules/forms/labelled-select.tsx b/src/components/molecules/forms/labelled-select.tsx new file mode 100644 index 0000000..23057d0 --- /dev/null +++ b/src/components/molecules/forms/labelled-select.tsx @@ -0,0 +1,69 @@ +import Label, { type LabelProps } from '@components/atoms/forms/label'; +import Select, { type SelectProps } from '@components/atoms/forms/select'; +import { FC } from 'react'; +import styles from './labelled-select.module.scss'; + +export type LabelledSelectProps = Omit< + SelectProps, + 'aria-labelledby' | 'className' +> & { + /** + * The field label. + */ + label: string; + /** + * Set additional classnames to the label. + */ + labelClassName?: LabelProps['className']; + /** + * The label position. Default: top. + */ + labelPosition?: 'left' | 'top'; + /** + * The label size. + */ + labelSize?: LabelProps['size']; + /** + * Set additional classnames to the select field. + */ + selectClassName?: SelectProps['className']; +}; + +/** + * LabelledSelect component + * + * Render a select with a label. + */ +const LabelledSelect: FC<LabelledSelectProps> = ({ + id, + label, + labelClassName = '', + labelPosition = 'top', + labelSize, + required, + selectClassName = '', + ...props +}) => { + const positionModifier = `label--${labelPosition}`; + + return ( + <> + <Label + htmlFor={id} + required={required} + size={labelSize} + className={`${styles[positionModifier]} ${labelClassName}`} + > + {label} + </Label> + <Select + id={id} + required={required} + {...props} + className={selectClassName} + /> + </> + ); +}; + +export default LabelledSelect; diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx new file mode 100644 index 0000000..60430d5 --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.stories.tsx @@ -0,0 +1,57 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import MotionToggleComponent from './motion-toggle'; + +/** + * MotionToggle - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Toggle', + component: MotionToggleComponent, + argTypes: { + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + storageKey: { + control: { + type: 'text', + }, + description: 'Set local storage key.', + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'The reduce motion value.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof MotionToggleComponent>; + +const Template: ComponentStory<typeof MotionToggleComponent> = (args) => ( + <MotionToggleComponent {...args} /> +); + +/** + * Toggle Stories - Motion + */ +export const Motion = Template.bind({}); +Motion.args = { + value: false, +}; diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx new file mode 100644 index 0000000..4fd6b31 --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import MotionToggle from './motion-toggle'; + +describe('MotionToggle', () => { + it('renders a checked toggle (deactivate animations choice)', () => { + render(<MotionToggle storageKey="reduced-motion" value={true} />); + expect( + screen.getByRole('checkbox', { + name: `Animations: On Off`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx new file mode 100644 index 0000000..cbe38fe --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.tsx @@ -0,0 +1,75 @@ +import Toggle, { + type ToggleChoices, + type ToggleProps, +} from '@components/molecules/forms/toggle'; +import useAttributes from '@utils/hooks/use-attributes'; +import useLocalStorage from '@utils/hooks/use-local-storage'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; + +export type MotionToggleProps = Pick< + ToggleProps, + 'labelClassName' | 'value' +> & { + /** + * The local storage key to save preference. + */ + storageKey: string; +}; + +/** + * MotionToggle component + * + * Render a Toggle component to set reduce motion. + */ +const MotionToggle: FC<MotionToggleProps> = ({ + storageKey, + value, + ...props +}) => { + const intl = useIntl(); + const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>( + storageKey, + value + ); + useAttributes({ + element: document.documentElement || undefined, + attribute: 'reduced-motion', + value: `${isReduced}`, + }); + + const reduceMotionLabel = intl.formatMessage({ + defaultMessage: 'Animations:', + description: 'MotionToggle: reduce motion label', + id: '/q5csZ', + }); + const onLabel = intl.formatMessage({ + defaultMessage: 'On', + description: 'MotionToggle: activate reduce motion label', + id: 'va65iw', + }); + const offLabel = intl.formatMessage({ + defaultMessage: 'Off', + description: 'MotionToggle: deactivate reduce motion label', + id: 'pWKyyR', + }); + const reduceMotionChoices: ToggleChoices = { + left: onLabel, + right: offLabel, + }; + + return ( + <Toggle + id="reduce-motion-settings" + name="reduce-motion-settings" + label={reduceMotionLabel} + labelSize="medium" + choices={reduceMotionChoices} + value={isReduced} + setValue={setIsReduced} + {...props} + /> + ); +}; + +export default MotionToggle; diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx new file mode 100644 index 0000000..ef4ed6e --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx @@ -0,0 +1,34 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import PrismThemeToggle from './prism-theme-toggle'; + +/** + * PrismThemeToggle - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Toggle', + component: PrismThemeToggle, + argTypes: { + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof PrismThemeToggle>; + +const Template: ComponentStory<typeof PrismThemeToggle> = (args) => ( + <PrismThemeToggle {...args} /> +); + +/** + * Toggle Stories - Prism theme + */ +export const PrismTheme = Template.bind({}); diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx new file mode 100644 index 0000000..c9d7894 --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import PrismThemeToggle from './prism-theme-toggle'; + +describe('PrismThemeToggle', () => { + it('renders a toggle component', () => { + render(<PrismThemeToggle />); + expect( + screen.getByRole('checkbox', { + name: `Code blocks: Light theme Dark theme`, + }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx new file mode 100644 index 0000000..3320722 --- /dev/null +++ b/src/components/molecules/forms/prism-theme-toggle.tsx @@ -0,0 +1,73 @@ +import Moon from '@components/atoms/icons/moon'; +import Sun from '@components/atoms/icons/sun'; +import Toggle, { + type ToggleChoices, + type ToggleProps, +} from '@components/molecules/forms/toggle'; +import { usePrismTheme } from '@utils/providers/prism-theme'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; + +export type PrismThemeToggleProps = Pick<ToggleProps, 'labelClassName'>; + +/** + * PrismThemeToggle component + * + * Render a Toggle component to set code blocks theme. + */ +const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => { + const intl = useIntl(); + const { theme, setTheme, resolvedTheme } = usePrismTheme(); + + /** + * Check if the resolved or chosen theme is dark theme. + * + * @returns {boolean} True if it is dark theme. + */ + const isDarkTheme = (): boolean => { + if (theme === 'system') return resolvedTheme === 'dark'; + return theme === 'dark'; + }; + + /** + * Update the theme. + */ + const updateTheme = () => { + setTheme(isDarkTheme() ? 'light' : 'dark'); + }; + + const themeLabel = intl.formatMessage({ + defaultMessage: 'Code blocks:', + description: 'PrismThemeToggle: theme label', + id: 'ftXN+0', + }); + const lightThemeLabel = intl.formatMessage({ + defaultMessage: 'Light theme', + description: 'PrismThemeToggle: light theme label', + id: 'tsWh8x', + }); + const darkThemeLabel = intl.formatMessage({ + defaultMessage: 'Dark theme', + description: 'PrismThemeToggle: dark theme label', + id: 'og/zWL', + }); + const themeChoices: ToggleChoices = { + left: <Sun title={lightThemeLabel} />, + right: <Moon title={darkThemeLabel} />, + }; + + return ( + <Toggle + id="prism-theme-settings" + name="prism-theme-settings" + label={themeLabel} + labelSize="medium" + choices={themeChoices} + value={isDarkTheme()} + setValue={updateTheme} + {...props} + /> + ); +}; + +export default PrismThemeToggle; diff --git a/src/components/molecules/forms/select-with-tooltip.module.scss b/src/components/molecules/forms/select-with-tooltip.module.scss new file mode 100644 index 0000000..bfadece --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.module.scss @@ -0,0 +1,48 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + position: relative; +} + +.select { + width: auto; + + @include mix.pointer("fine") { + padding: fun.convert-px(3) var(--spacing-xs); + } +} + +.btn { + margin-left: var(--spacing-xs); + + &--activated { + background: var(--color-primary); + + * { + color: var(--color-fg-inverted); + } + } +} + +.tooltip { + position: absolute; + top: calc(100% + var(--spacing-xs)); + transform-origin: top; + transition: all 0.75s ease-in-out 0s; + + &--hidden { + opacity: 0; + visibility: hidden; + transform: scale(0); + } + + &--visible { + opacity: 1; + visibility: visible; + transform: scale(1); + } +} diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx new file mode 100644 index 0000000..ddf5d4c --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.stories.tsx @@ -0,0 +1,210 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import SelectWithTooltip from './select-with-tooltip'; + +/** + * SelectWithTooltip - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Select', + component: SelectWithTooltip, + argTypes: { + content: { + control: { + type: 'text', + }, + description: 'The tooltip body.', + type: { + name: 'string', + required: true, + }, + }, + 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: 'Field id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The select label.', + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + 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', + }, + description: 'Field name.', + type: { + name: 'string', + required: true, + }, + }, + options: { + control: { + type: null, + }, + description: 'Select options.', + type: { + name: 'array', + required: true, + value: { + name: 'string', + }, + }, + }, + required: { + control: { + type: 'boolean', + }, + description: 'Determine if the field is required.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + 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, + }, + description: 'Callback function to set field value.', + table: { + category: 'Events', + }, + type: { + name: 'function', + 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', + }, + description: 'Field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SelectWithTooltip>; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; + +const Template: ComponentStory<typeof SelectWithTooltip> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [selected, setSelected] = useState<string>('option1'); + return ( + <SelectWithTooltip value={selected} setValue={setSelected} {...args} /> + ); +}; + +/** + * Select Stories - With tooltip + */ +export const WithTooltip = Template.bind({}); +WithTooltip.args = { + content: 'Illo voluptatibus quia minima placeat sit nostrum excepturi.', + title: 'Possimus quidem dolor', + id: 'storybook-select', + label: 'Officiis:', + name: 'storybook-select', + options: selectOptions, +}; diff --git a/src/components/molecules/forms/select-with-tooltip.test.tsx b/src/components/molecules/forms/select-with-tooltip.test.tsx new file mode 100644 index 0000000..7a423f5 --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@test-utils'; +import SelectWithTooltip from './select-with-tooltip'; + +const selectOptions = [ + { id: 'option1', name: 'Option 1', value: 'option1' }, + { id: 'option2', name: 'Option 2', value: 'option2' }, + { id: 'option3', name: 'Option 3', value: 'option3' }, +]; +const selectLabel = 'Jest select'; +const selectValue = selectOptions[0].value; +const tooltipTitle = 'Jest tooltip'; +const tooltipContent = 'Nesciunt voluptatibus voluptatem omnis at quia libero.'; + +describe('SelectWithTooltip', () => { + it('renders a select', () => { + render( + <SelectWithTooltip + id="jest-select" + name="jest-select" + label={selectLabel} + options={selectOptions} + value={selectValue} + setValue={() => null} + title={tooltipTitle} + content={tooltipContent} + /> + ); + expect(screen.getByRole('combobox', { name: selectLabel })).toHaveValue( + selectValue + ); + }); +}); diff --git a/src/components/molecules/forms/select-with-tooltip.tsx b/src/components/molecules/forms/select-with-tooltip.tsx new file mode 100644 index 0000000..29e2563 --- /dev/null +++ b/src/components/molecules/forms/select-with-tooltip.tsx @@ -0,0 +1,73 @@ +import useClickOutside from '@utils/hooks/use-click-outside'; +import { FC, useRef, useState } from 'react'; +import HelpButton from '../buttons/help-button'; +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 = Omit< + LabelledSelectProps, + 'labelPosition' +> & + Pick<TooltipProps, 'title' | 'content'> & { + /** + * Set additional classnames to the tooltip wrapper. + */ + tooltipClassName?: TooltipProps['className']; + }; + +/** + * SelectWithTooltip component + * + * Render a select with a button to display a tooltip about options. + */ +const SelectWithTooltip: FC<SelectWithTooltipProps> = ({ + title, + content, + id, + tooltipClassName = '', + ...props +}) => { + const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false); + const buttonRef = useRef<HTMLButtonElement>(null); + const tooltipRef = useRef<HTMLDivElement>(null); + const buttonModifier = isTooltipOpened ? styles['btn--activated'] : ''; + const tooltipModifier = isTooltipOpened + ? styles['tooltip--visible'] + : styles['tooltip--hidden']; + + const closeTooltip = (target: EventTarget) => { + if (buttonRef.current && !buttonRef.current.contains(target as Node)) + setIsTooltipOpened(false); + }; + + useClickOutside( + tooltipRef, + (target) => isTooltipOpened && closeTooltip(target) + ); + + return ( + <div className={styles.wrapper}> + <LabelledSelect + labelPosition="left" + id={id} + labelClassName={styles.label} + {...props} + /> + <HelpButton + className={`${styles.btn} ${buttonModifier}`} + onClick={() => setIsTooltipOpened(!isTooltipOpened)} + ref={buttonRef} + /> + <Tooltip + title={title} + content={content} + icon="?" + className={`${styles.tooltip} ${tooltipModifier} ${tooltipClassName}`} + ref={tooltipRef} + /> + </div> + ); +}; + +export default SelectWithTooltip; diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx new file mode 100644 index 0000000..5ebf5a2 --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.stories.tsx @@ -0,0 +1,34 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ThemeToggle from './theme-toggle'; + +/** + * ThemeToggle - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Toggle', + component: ThemeToggle, + argTypes: { + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ThemeToggle>; + +const Template: ComponentStory<typeof ThemeToggle> = (args) => ( + <ThemeToggle {...args} /> +); + +/** + * Toggle Stories - Theme + */ +export const Theme = Template.bind({}); diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx new file mode 100644 index 0000000..0600c5e --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@test-utils'; +import ThemeToggle from './theme-toggle'; + +describe('ThemeToggle', () => { + it('renders a toggle component', () => { + render(<ThemeToggle />); + expect( + screen.getByRole('checkbox', { + name: `Theme: Light theme Dark theme`, + }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx new file mode 100644 index 0000000..61ee4c6 --- /dev/null +++ b/src/components/molecules/forms/theme-toggle.tsx @@ -0,0 +1,64 @@ +import Moon from '@components/atoms/icons/moon'; +import Sun from '@components/atoms/icons/sun'; +import Toggle, { + type ToggleChoices, + type ToggleProps, +} from '@components/molecules/forms/toggle'; +import { useTheme } from 'next-themes'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; + +export type ThemeToggleProps = Pick<ToggleProps, 'labelClassName'>; + +/** + * ThemeToggle component + * + * Render a Toggle component to set theme. + */ +const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => { + const intl = useIntl(); + const { resolvedTheme, setTheme } = useTheme(); + const isDarkTheme = resolvedTheme === 'dark'; + + /** + * Update the theme. + */ + const updateTheme = () => { + setTheme(isDarkTheme ? 'light' : 'dark'); + }; + + const themeLabel = intl.formatMessage({ + defaultMessage: 'Theme:', + description: 'ThemeToggle: theme label', + id: 'suXOBu', + }); + const lightThemeLabel = intl.formatMessage({ + defaultMessage: 'Light theme', + description: 'ThemeToggle: light theme label', + id: 'Ygea7s', + }); + const darkThemeLabel = intl.formatMessage({ + defaultMessage: 'Dark theme', + description: 'ThemeToggle: dark theme label', + id: '2QwvtS', + }); + const themeChoices: ToggleChoices = { + left: <Sun title={lightThemeLabel} />, + right: <Moon title={darkThemeLabel} />, + }; + + return ( + <Toggle + id="theme-settings" + name="theme-settings" + label={themeLabel} + labelSize="medium" + choices={themeChoices} + value={isDarkTheme} + setValue={updateTheme} + {...props} + /> + ); +}; + +export default ThemeToggle; diff --git a/src/components/molecules/forms/toggle.module.scss b/src/components/molecules/forms/toggle.module.scss new file mode 100644 index 0000000..2e8a49f --- /dev/null +++ b/src/components/molecules/forms/toggle.module.scss @@ -0,0 +1,75 @@ +@use "@styles/abstracts/functions" as fun; + +.label { + --toggle-width: #{fun.convert-px(45)}; + --toggle-height: calc(var(--toggle-width) / 2); + + display: inline-flex; + align-items: center; + width: 100%; +} + +.title { + margin-right: var(--spacing-2xs); +} + +.toggle { + display: inline-flex; + align-items: center; + width: var(--toggle-width); + height: var(--toggle-height); + background: var(--color-shadow-light); + border: fun.convert-px(1) solid var(--color-primary); + border-radius: fun.convert-px(32); + box-shadow: inset 0 0 fun.convert-px(3) 0 var(--color-shadow-dark); + margin: 0 var(--spacing-2xs); + position: relative; + + &::after { + content: ""; + display: block; + width: calc((var(--toggle-width) / 2) - 1px); + height: calc((var(--toggle-width) / 2) - 1px); + background: var(--color-primary-light); + border: fun.convert-px(1) solid var(--color-primary); + border-radius: 50%; + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1) + var(--color-shadow), + 0 0 fun.convert-px(2) fun.convert-px(1) var(--color-shadow-light); + position: absolute; + left: fun.convert-px(-2); + transition: all 0.3s ease-in-out 0s; + } +} + +.checkbox { + position: absolute; + opacity: 0; + cursor: pointer; + + &:checked ~ .label { + .toggle::after { + position: absolute; + left: calc(100% - (var(--toggle-width) / 2) + #{fun.convert-px(2)}); + } + } + + &:hover, + &:focus { + ~ .label { + .toggle::after { + background: var(--color-primary-lighter); + } + } + } + + &:focus ~ .label { + .title { + text-decoration: underline solid var(--color-primary) fun.convert-px(2); + } + + .toggle { + outline: var(--color-border) solid fun.convert-px(5); + } + } +} diff --git a/src/components/molecules/forms/toggle.stories.tsx b/src/components/molecules/forms/toggle.stories.tsx new file mode 100644 index 0000000..0351ab7 --- /dev/null +++ b/src/components/molecules/forms/toggle.stories.tsx @@ -0,0 +1,121 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import Toggle from './toggle'; + +/** + * ThemeToggle - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Toggle', + component: Toggle, + argTypes: { + choices: { + description: 'The toggle choices.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The input id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The toggle label.', + type: { + name: 'string', + required: true, + }, + }, + labelClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the label.', + table: { + category: 'Styles', + }, + 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', + }, + description: 'The input name.', + type: { + name: 'string', + required: true, + }, + }, + setValue: { + control: { + type: null, + }, + description: 'A callback function to update the toggle value.', + type: { + name: 'function', + required: true, + }, + }, + value: { + control: { + type: null, + }, + description: 'The toggle value. True if checked.', + type: { + name: 'boolean', + required: true, + }, + }, + }, +} as ComponentMeta<typeof Toggle>; + +const Template: ComponentStory<typeof Toggle> = ({ + value: _value, + setValue: _setValue, + ...args +}) => { + const [isChecked, setIsChecked] = useState<boolean>(false); + return <Toggle value={isChecked} setValue={setIsChecked} {...args} />; +}; + +/** + * Toggle Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + choices: { + left: 'On', + right: 'Off', + }, + id: 'toggle-example', + label: 'Activate setting:', + name: 'toggle-example', +}; diff --git a/src/components/molecules/forms/toggle.test.tsx b/src/components/molecules/forms/toggle.test.tsx new file mode 100644 index 0000000..fb97adc --- /dev/null +++ b/src/components/molecules/forms/toggle.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@test-utils'; +import Toggle from './toggle'; + +const choices = { + left: 'On', + right: 'Off', +}; + +const label = 'Activate this setting:'; + +describe('Toggle', () => { + it('renders a checked toggle', () => { + render( + <Toggle + id="toggle-example" + name="toggle-example" + choices={choices} + label={label} + value={true} + setValue={(__value) => null} + /> + ); + expect( + screen.getByRole('checkbox', { + name: `${label} ${choices.left} ${choices.right}`, + }) + ).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/toggle.tsx b/src/components/molecules/forms/toggle.tsx new file mode 100644 index 0000000..288062d --- /dev/null +++ b/src/components/molecules/forms/toggle.tsx @@ -0,0 +1,78 @@ +import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox'; +import Label, { type LabelProps } from '@components/atoms/forms/label'; +import { FC, ReactNode } from 'react'; +import styles from './toggle.module.scss'; + +export type ToggleChoices = { + /** + * The left part of the toggle field (unchecked). + */ + left: ReactNode; + /** + * The right part of the toggle field (checked). + */ + right: ReactNode; +}; + +export type ToggleProps = Pick<CheckboxProps, 'id' | 'name'> & { + /** + * The toggle choices. + */ + choices: ToggleChoices; + /** + * The toggle label. + */ + label: string; + /** + * Set additional classnames to the label. + */ + labelClassName?: LabelProps['className']; + /** + * The label size. + */ + labelSize?: LabelProps['size']; + /** + * The toggle value. True if checked. + */ + value: boolean; + /** + * A callback function to update the toggle value. + */ + setValue: (value: boolean) => void; +}; + +/** + * Toggle component + * + * Render a toggle with a label and two choices. + */ +const Toggle: FC<ToggleProps> = ({ + choices, + id, + label, + labelClassName = '', + labelSize, + name, + setValue, + value, +}) => { + return ( + <> + <Checkbox + name={name} + id={id} + value={value} + setValue={() => setValue(!value)} + className={styles.checkbox} + /> + <Label size={labelSize} htmlFor={id} className={styles.label}> + <span className={`${styles.title} ${labelClassName}`}>{label}</span> + {choices.left} + <span className={styles.toggle}></span> + {choices.right} + </Label> + </> + ); +}; + +export default Toggle; |
