diff options
Diffstat (limited to 'src/components')
66 files changed, 2369 insertions, 1254 deletions
diff --git a/src/components/atoms/forms/boolean-field.module.scss b/src/components/atoms/forms/boolean-field.module.scss new file mode 100644 index 0000000..3f0676e --- /dev/null +++ b/src/components/atoms/forms/boolean-field.module.scss @@ -0,0 +1,5 @@ +@use "@styles/abstracts/mixins" as mix; + +.hidden { + @include mix.visually-hidden; +} diff --git a/src/components/atoms/forms/boolean-field.stories.tsx b/src/components/atoms/forms/boolean-field.stories.tsx new file mode 100644 index 0000000..8b6136b --- /dev/null +++ b/src/components/atoms/forms/boolean-field.stories.tsx @@ -0,0 +1,175 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import BooleanFieldComponent from './boolean-field'; + +/** + * BooleanField - Storybook Meta + */ +export default { + title: 'Atoms/Forms', + component: BooleanFieldComponent, + args: { + hidden: 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, + }, + }, + checked: { + control: { + type: null, + }, + description: 'The field state: true if checked.', + type: { + name: 'boolean', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + hidden: { + control: { + type: 'boolean', + }, + description: 'Define if the field should be visually hidden.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The field id.', + type: { + name: 'string', + required: true, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'The field name.', + type: { + name: 'string', + required: true, + }, + }, + onChange: { + control: { + type: null, + }, + description: 'A callback function to handle field state change.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click on field.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'The field type. Either checkbox or radio.', + options: ['checkbox', 'radio'], + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: 'text', + }, + description: 'The field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof BooleanFieldComponent>; + +const Template: ComponentStory<typeof BooleanFieldComponent> = ({ + checked, + onChange: _onChange, + ...args +}) => { + const [isChecked, setIsChecked] = useState<boolean>(checked); + + return ( + <BooleanFieldComponent + checked={isChecked} + onChange={() => { + setIsChecked(!isChecked); + }} + {...args} + /> + ); +}; + +/** + * Checkbox Story + */ +export const Checkbox = Template.bind({}); +Checkbox.args = { + id: 'checkbox', + checked: false, + name: 'checkbox', + type: 'checkbox', + value: 'checkbox', +}; + +/** + * Radio Story + */ +export const Radio = Template.bind({}); +Radio.args = { + id: 'radio', + checked: false, + name: 'radio', + type: 'radio', + value: 'radio', +}; diff --git a/src/components/atoms/forms/boolean-field.test.tsx b/src/components/atoms/forms/boolean-field.test.tsx new file mode 100644 index 0000000..95ec4b1 --- /dev/null +++ b/src/components/atoms/forms/boolean-field.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@test-utils'; +import BooleanField from './boolean-field'; + +describe('BooleanField', () => { + it('renders an unchecked checkbox', () => { + render( + <BooleanField + checked={false} + id="jest-checkbox" + name="jest-checkbox" + onChange={() => null} + type="checkbox" + value="checkbox" + /> + ); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders a checked checkbox', () => { + render( + <BooleanField + checked={true} + id="jest-checkbox" + name="jest-checkbox" + onChange={() => null} + type="checkbox" + value="checkbox" + /> + ); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('renders an unchecked radio', () => { + render( + <BooleanField + checked={false} + id="jest-radio" + name="jest-radio" + onChange={() => null} + type="radio" + value="radio" + /> + ); + expect(screen.getByRole('radio')).not.toBeChecked(); + }); + + it('renders a checked radio', () => { + render( + <BooleanField + checked={true} + id="jest-radio" + name="jest-radio" + onChange={() => null} + type="radio" + value="radio" + /> + ); + expect(screen.getByRole('radio')).toBeChecked(); + }); +}); diff --git a/src/components/atoms/forms/boolean-field.tsx b/src/components/atoms/forms/boolean-field.tsx new file mode 100644 index 0000000..946e375 --- /dev/null +++ b/src/components/atoms/forms/boolean-field.tsx @@ -0,0 +1,62 @@ +import { ChangeEventHandler, FC, MouseEventHandler } from 'react'; +import styles from './boolean-field.module.scss'; + +export type BooleanFieldProps = { + /** + * One or more ids that refers to the checkbox name. + */ + 'aria-labelledby'?: string; + /** + * True if the field should be checked. + */ + checked: boolean; + /** + * Add classnames to the checkbox. + */ + className?: string; + /** + * Field id attribute. + */ + id: string; + /** + * True if the field should be visually hidden. Default: false. + */ + hidden?: boolean; + /** + * Field name attribute. + */ + name: string; + /** + * Callback function to handle state change. + */ + onChange: ChangeEventHandler<HTMLInputElement>; + /** + * A callback function to handle click. + */ + onClick?: MouseEventHandler<HTMLInputElement>; + /** + * The input type. + */ + type: 'checkbox' | 'radio'; + /** + * Field name attribute. + */ + value: string; +}; + +/** + * BooleanField component + * + * Render a checkbox or a radio input type. + */ +const BooleanField: FC<BooleanFieldProps> = ({ + className = '', + hidden = false, + ...props +}) => { + const modifier = hidden ? 'hidden' : ''; + + return <input className={`${styles[modifier]} ${className}`} {...props} />; +}; + +export default BooleanField; diff --git a/src/components/atoms/forms/checkbox.stories.tsx b/src/components/atoms/forms/checkbox.stories.tsx deleted file mode 100644 index 588fdcc..0000000 --- a/src/components/atoms/forms/checkbox.stories.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { useState } from 'react'; -import CheckboxComponent from './checkbox'; - -/** - * Checkbox - Storybook Meta - */ -export default { - title: 'Atoms/Forms', - component: CheckboxComponent, - argTypes: { - 'aria-labelledby': { - control: { - type: 'text', - }, - description: 'One or more ids that refers to the checkbox name.', - table: { - category: 'Accessibility', - }, - type: { - name: 'string', - required: false, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the checkbox.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - id: { - control: { - type: 'text', - }, - description: 'The checkbox id.', - type: { - name: 'string', - required: true, - }, - }, - name: { - control: { - type: 'text', - }, - description: 'The checkbox name.', - type: { - name: 'string', - required: true, - }, - }, - setValue: { - control: { - type: null, - }, - description: 'A callback function to handle checkbox state.', - type: { - name: 'function', - required: true, - }, - }, - value: { - control: { - type: null, - }, - description: - 'The checkbox state: either checked (true) or unchecked (false).', - type: { - name: 'boolean', - required: true, - }, - }, - }, -} as ComponentMeta<typeof CheckboxComponent>; - -const Template: ComponentStory<typeof CheckboxComponent> = ({ - value, - setValue: _setValue, - ...args -}) => { - const [isChecked, setIsChecked] = useState<boolean>(value); - - return ( - <CheckboxComponent value={isChecked} setValue={setIsChecked} {...args} /> - ); -}; - -/** - * Checkbox Story - */ -export const Checkbox = Template.bind({}); -Checkbox.args = { - id: 'storybook-checkbox', - name: 'storybook-checkbox', - value: false, -}; diff --git a/src/components/atoms/forms/checkbox.test.tsx b/src/components/atoms/forms/checkbox.test.tsx deleted file mode 100644 index 3b54549..0000000 --- a/src/components/atoms/forms/checkbox.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { render, screen } from '@test-utils'; -import Checkbox from './checkbox'; - -describe('Checkbox', () => { - it('renders an unchecked checkbox', () => { - render( - <Checkbox - id="jest-checkbox" - name="jest-checkbox" - value={false} - setValue={() => null} - /> - ); - expect(screen.getByRole('checkbox')).not.toBeChecked(); - }); - - it('renders a checked checkbox', () => { - render( - <Checkbox - id="jest-checkbox" - name="jest-checkbox" - value={true} - setValue={() => null} - /> - ); - expect(screen.getByRole('checkbox')).toBeChecked(); - }); -}); diff --git a/src/components/atoms/forms/checkbox.tsx b/src/components/atoms/forms/checkbox.tsx deleted file mode 100644 index aec97f0..0000000 --- a/src/components/atoms/forms/checkbox.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { FC, SetStateAction } from 'react'; - -export type CheckboxProps = { - /** - * One or more ids that refers to the checkbox name. - */ - 'aria-labelledby'?: string; - /** - * Add classnames to the checkbox. - */ - className?: string; - /** - * Checkbox id attribute. - */ - id: string; - /** - * Checkbox name attribute. - */ - name: string; - /** - * Callback function to set checkbox value. - */ - setValue: (value: SetStateAction<boolean>) => void; - /** - * Checkbox value. - */ - value: boolean; -}; - -/** - * Checkbox component - * - * Render a checkbox type input. - */ -const Checkbox: FC<CheckboxProps> = ({ value, setValue, ...props }) => { - return ( - <input - type="checkbox" - checked={value} - onChange={() => setValue(!value)} - {...props} - /> - ); -}; - -export default Checkbox; diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx deleted file mode 100644 index 81eb5df..0000000 --- a/src/components/molecules/forms/ackee-select.stories.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; -import AckeeSelect from './ackee-select'; - -/** - * AckeeSelect - Storybook Meta - */ -export default { - title: 'Molecules/Forms/Select', - component: AckeeSelect, - argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the select wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - 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 deleted file mode 100644 index 0089c06..0000000 --- a/src/components/molecules/forms/ackee-select.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index f00ca74..0000000 --- a/src/components/molecules/forms/ackee-select.tsx +++ /dev/null @@ -1,103 +0,0 @@ -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, - 'className' | '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/ackee-toggle.fixture.tsx b/src/components/molecules/forms/ackee-toggle.fixture.tsx new file mode 100644 index 0000000..04602f2 --- /dev/null +++ b/src/components/molecules/forms/ackee-toggle.fixture.tsx @@ -0,0 +1 @@ +export const storageKey = 'ackee'; diff --git a/src/components/molecules/forms/ackee-select.module.scss b/src/components/molecules/forms/ackee-toggle.module.scss index 87cd9ee..f238bda 100644 --- a/src/components/molecules/forms/ackee-select.module.scss +++ b/src/components/molecules/forms/ackee-toggle.module.scss @@ -4,8 +4,3 @@ align-items: center; position: relative; } - -.tooltip { - position: absolute; - bottom: -100%; -} diff --git a/src/components/molecules/forms/ackee-toggle.stories.tsx b/src/components/molecules/forms/ackee-toggle.stories.tsx new file mode 100644 index 0000000..bbc6fb4 --- /dev/null +++ b/src/components/molecules/forms/ackee-toggle.stories.tsx @@ -0,0 +1,112 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import AckeeToggleComponent from './ackee-toggle'; +import { storageKey } from './ackee-toggle.fixture'; + +/** + * AckeeToggle - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Toggle', + component: AckeeToggleComponent, + argTypes: { + bodyClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the fieldset body wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the toggle wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + defaultValue: { + control: { + type: 'select', + }, + description: 'Set the default value.', + options: ['full', 'partial'], + type: { + name: 'string', + required: true, + }, + }, + groupClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the radio group wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legendClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the legend.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + storageKey: { + control: { + type: 'text', + }, + description: 'Set 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 AckeeToggleComponent>; + +const Template: ComponentStory<typeof AckeeToggleComponent> = (args) => ( + <AckeeToggleComponent {...args} /> +); + +/** + * Toggle Stories - Ackee + */ +export const Ackee = Template.bind({}); +Ackee.args = { + defaultValue: 'full', + storageKey, +}; diff --git a/src/components/molecules/forms/ackee-toggle.test.tsx b/src/components/molecules/forms/ackee-toggle.test.tsx new file mode 100644 index 0000000..8a57ce7 --- /dev/null +++ b/src/components/molecules/forms/ackee-toggle.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from '@test-utils'; +import AckeeToggle from './ackee-toggle'; +import { storageKey } from './ackee-toggle.fixture'; + +describe('AckeeToggle', () => { + // toHaveValue received undefined. Maybe because of localStorage hook... + it('renders a toggle component', () => { + render(<AckeeToggle storageKey={storageKey} defaultValue="full" />); + expect( + screen.getByRole('radiogroup', { + name: /Tracking:/i, + }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/molecules/forms/ackee-toggle.tsx b/src/components/molecules/forms/ackee-toggle.tsx new file mode 100644 index 0000000..a666731 --- /dev/null +++ b/src/components/molecules/forms/ackee-toggle.tsx @@ -0,0 +1,143 @@ +import useLocalStorage from '@utils/hooks/use-local-storage'; +import useUpdateAckeeOptions, { + type AckeeOptions, +} from '@utils/hooks/use-update-ackee-options'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; +import RadioGroup, { + type RadioGroupCallback, + type RadioGroupCallbackProps, + type RadioGroupOption, + type RadioGroupProps, +} from './radio-group'; +import Tooltip, { type TooltipProps } from '../modals/tooltip'; + +export type AckeeToggleProps = Pick< + RadioGroupProps, + 'bodyClassName' | 'groupClassName' | 'legendClassName' +> & { + /** + * Set additional classnames to the toggle wrapper. + */ + className?: string; + /** + * True if motion should be reduced by default. + */ + defaultValue: AckeeOptions; + /** + * The local storage key to save preference. + */ + storageKey: string; + /** + * Set additional classnames to the tooltip wrapper. + */ + tooltipClassName?: TooltipProps['className']; +}; + +/** + * AckeeToggle component + * + * Render a Toggle component to set reduce motion. + */ +const AckeeToggle: FC<AckeeToggleProps> = ({ + defaultValue, + storageKey, + tooltipClassName, + ...props +}) => { + const intl = useIntl(); + const { value, setValue } = useLocalStorage<AckeeOptions>( + storageKey, + defaultValue + ); + useUpdateAckeeOptions(value); + + const ackeeLabel = intl.formatMessage({ + defaultMessage: 'Tracking:', + description: 'AckeeToggle: select label', + id: '0gVlI3', + }); + const tooltipTitle = intl.formatMessage({ + defaultMessage: 'Ackee tracking (analytics)', + description: 'AckeeToggle: tooltip title', + id: 'nGss/j', + }); + const tooltipContent = [ + intl.formatMessage({ + defaultMessage: 'Partial includes only page url, views and duration.', + description: 'AckeeToggle: tooltip message', + id: 'ZB/Aw2', + }), + intl.formatMessage({ + defaultMessage: + 'Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.', + description: 'AckeeToggle: tooltip message', + id: '7zDlQo', + }), + ]; + const partialLabel = intl.formatMessage({ + defaultMessage: 'Partial', + description: 'AckeeToggle: partial option name', + id: 'tIZYpD', + }); + const fullLabel = intl.formatMessage({ + defaultMessage: 'Full', + description: 'AckeeToggle: full option name', + id: '5eD6y2', + }); + + const options: RadioGroupOption[] = [ + { + id: 'ackee-full', + label: fullLabel, + name: 'ackee', + value: 'full', + }, + { + id: 'ackee-partial', + label: partialLabel, + name: 'ackee', + value: 'partial', + }, + ]; + + /** + * Handle change events. + * + * @param {RadioGroupCallbackProps} props - An object with choices. + */ + const handleChange: RadioGroupCallback = ({ + choices, + updateChoice, + }: RadioGroupCallbackProps) => { + let newChoice: AckeeOptions = choices.new as AckeeOptions; + + if (choices.new === choices.prev) { + newChoice = choices.new === 'full' ? 'partial' : 'full'; + updateChoice(newChoice); + } + + setValue(newChoice); + }; + + return ( + <RadioGroup + initialChoice={value} + kind="toggle" + legend={ackeeLabel} + onChange={handleChange} + options={options} + Tooltip={ + <Tooltip + title={tooltipTitle} + content={tooltipContent} + icon="?" + className={tooltipClassName} + /> + } + {...props} + /> + ); +}; + +export default AckeeToggle; diff --git a/src/components/molecules/forms/fieldset.fixture.tsx b/src/components/molecules/forms/fieldset.fixture.tsx new file mode 100644 index 0000000..b94f340 --- /dev/null +++ b/src/components/molecules/forms/fieldset.fixture.tsx @@ -0,0 +1,6 @@ +import { TooltipProps } from '../modals/tooltip'; +import { Help } from '../modals/tooltip.stories'; + +export const body = 'doloribus magni aut'; +export const legend = 'maiores autem est'; +export const Tooltip = <Help {...(Help.args as TooltipProps)} />; diff --git a/src/components/molecules/forms/select-with-tooltip.module.scss b/src/components/molecules/forms/fieldset.module.scss index bfadece..3102bf7 100644 --- a/src/components/molecules/forms/select-with-tooltip.module.scss +++ b/src/components/molecules/forms/fieldset.module.scss @@ -1,23 +1,16 @@ -@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); +.legend { + float: left; + color: var(--color-primary-darker); + font-size: var(--font-size-md); + font-weight: 600; + + &#{&}--has-tooltip { + padding: 0 var(--spacing-xs) 0 0; } } .btn { - margin-left: var(--spacing-xs); + margin: 0 var(--spacing-2xs) var(--spacing-2xs) 0; &--activated { background: var(--color-primary); @@ -29,12 +22,12 @@ } .tooltip { - position: absolute; top: calc(100% + var(--spacing-xs)); transform-origin: top; transition: all 0.75s ease-in-out 0s; &--hidden { + flex: 0 0 0; opacity: 0; visibility: hidden; transform: scale(0); @@ -46,3 +39,23 @@ transform: scale(1); } } + +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: center; + max-width: 100%; + padding: 0; + position: relative; + border: none; + + &--stacked { + .body { + flex: 1 0 100%; + } + } + + .tooltip { + position: absolute; + } +} diff --git a/src/components/molecules/forms/fieldset.stories.tsx b/src/components/molecules/forms/fieldset.stories.tsx new file mode 100644 index 0000000..0778094 --- /dev/null +++ b/src/components/molecules/forms/fieldset.stories.tsx @@ -0,0 +1,165 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { TooltipProps } from '../modals/tooltip'; +import { Help } from '../modals/tooltip.stories'; +import FieldsetComponent from './fieldset'; +import { body, legend, Tooltip } from './fieldset.fixture'; + +/** + * Fieldset - Storybook Meta + */ +export default { + title: 'Atoms/Forms/Fieldset', + component: FieldsetComponent, + args: { + legendPosition: 'stacked', + role: 'group', + }, + argTypes: { + bodyClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the body wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + children: { + control: { + type: null, + }, + description: 'The fieldset body.', + type: { + name: 'string', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the fieldset.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legend: { + control: { + type: 'text', + }, + description: 'The fieldset legend.', + type: { + name: 'string', + required: true, + }, + }, + legendClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the legend.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legendPosition: { + control: { + type: 'select', + }, + description: 'Determine the legend position.', + options: ['inline', 'stacked'], + table: { + category: 'Options', + defaultValue: { summary: 'inline' }, + }, + type: { + name: 'string', + required: false, + }, + }, + role: { + control: { + type: 'select', + }, + description: 'An accessible role.', + table: { + category: 'Accessibility', + defaultValue: { summary: 'group' }, + }, + options: ['group', 'radiogroup', 'presentation', 'none'], + type: { + name: 'string', + required: false, + }, + }, + Tooltip: { + control: { + type: null, + }, + description: 'Add an optional tooltip.', + table: { + category: 'Options', + }, + type: { + name: 'function', + required: false, + }, + }, + }, +} as ComponentMeta<typeof FieldsetComponent>; + +const Template: ComponentStory<typeof FieldsetComponent> = (args) => ( + <FieldsetComponent {...args} /> +); + +/** + * Fieldset Stories - Stacked legend + */ +export const StackedLegend = Template.bind({}); +StackedLegend.args = { + children: body, + legend: legend, +}; + +/** + * Fieldset Stories - Inlined legend + */ +export const InlinedLegend = Template.bind({}); +InlinedLegend.args = { + children: body, + legend: legend, + legendPosition: 'inline', +}; + +/** + * Fieldset Stories - Stacked legend with tooltip + */ +export const StackedLegendWithTooltip = Template.bind({}); +StackedLegendWithTooltip.args = { + children: body, + legend: legend, + Tooltip, +}; + +/** + * Fieldset Stories - Inlined legend with tooltip + */ +export const InlinedLegendWithTooltip = Template.bind({}); +InlinedLegendWithTooltip.args = { + children: body, + legend: legend, + legendPosition: 'inline', + Tooltip, +}; diff --git a/src/components/molecules/forms/fieldset.test.tsx b/src/components/molecules/forms/fieldset.test.tsx new file mode 100644 index 0000000..de89e31 --- /dev/null +++ b/src/components/molecules/forms/fieldset.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@test-utils'; +import Fieldset from './fieldset'; +import { body, legend, Tooltip } from './fieldset.fixture'; + +describe('Fieldset', () => { + // Cannot use toBeInTheDocument because of body is not an HTMLElement. + + it('renders a legend and a body', () => { + render(<Fieldset legend={legend}>{body}</Fieldset>); + expect(screen.findByRole('group', { name: legend })).toBeTruthy(); + expect(screen.findByText(body)).toBeTruthy(); + }); + + it('renders a button to open a tooltip', () => { + render( + <Fieldset legend={legend} Tooltip={Tooltip}> + {body} + </Fieldset> + ); + expect(screen.findByRole('button', { name: /Help/i })).toBeTruthy(); + }); +}); diff --git a/src/components/molecules/forms/fieldset.tsx b/src/components/molecules/forms/fieldset.tsx new file mode 100644 index 0000000..9f46247 --- /dev/null +++ b/src/components/molecules/forms/fieldset.tsx @@ -0,0 +1,118 @@ +import useClickOutside from '@utils/hooks/use-click-outside'; +import { + cloneElement, + FC, + ReactComponentElement, + ReactNode, + useRef, + useState, +} from 'react'; +import HelpButton from '../buttons/help-button'; +import Tooltip from '../modals/tooltip'; +import styles from './fieldset.module.scss'; + +export type FieldsetProps = { + /** + * Set additional classnames to the body wrapper. + */ + bodyClassName?: string; + /** + * The fieldset body. + */ + children: ReactNode | ReactNode[]; + /** + * Set additional classnames to the fieldset wrapper. + */ + className?: string; + /** + * The fieldset legend. + */ + legend: string; + /** + * Set additional classnames to the legend. + */ + legendClassName?: string; + /** + * The legend position. Default: stacked. + */ + legendPosition?: 'inline' | 'stacked'; + /** + * An accessible role. Default: group. + */ + role?: 'group' | 'radiogroup' | 'presentation' | 'none'; + /** + * An optional tooltip component. + */ + Tooltip?: ReactComponentElement<typeof Tooltip>; +}; + +/** + * Fieldset component + * + * Render a fieldset with a legend. + */ +const Fieldset: FC<FieldsetProps> = ({ + bodyClassName = '', + children, + className = '', + legend, + legendClassName = '', + legendPosition = 'stacked', + Tooltip: TooltipComponent, + ...props +}) => { + const [isTooltipOpened, setIsTooltipOpened] = useState<boolean>(false); + const buttonRef = useRef<HTMLButtonElement>(null); + const tooltipRef = useRef<HTMLDivElement>(null); + const wrapperModifier = `wrapper--${legendPosition}`; + const buttonModifier = isTooltipOpened ? styles['btn--activated'] : ''; + const legendModifier = + TooltipComponent === undefined ? '' : 'legend--has-tooltip'; + const tooltipModifier = isTooltipOpened + ? 'tooltip--visible' + : 'tooltip--hidden'; + + /** + * Close the tooltip if the event target is outside. + * + * @param {EventTarget} target - The event target. + */ + const closeTooltip = (target: EventTarget) => { + if (buttonRef.current && !buttonRef.current.contains(target as Node)) + setIsTooltipOpened(false); + }; + + useClickOutside( + tooltipRef, + (target) => isTooltipOpened && closeTooltip(target) + ); + + return ( + <fieldset + className={`${styles.wrapper} ${styles[wrapperModifier]} ${className}`} + {...props} + > + <legend + className={`${styles.legend} ${styles[legendModifier]} ${legendClassName}`} + > + {legend} + </legend> + {TooltipComponent && ( + <> + <HelpButton + className={`${styles.btn} ${buttonModifier}`} + onClick={() => setIsTooltipOpened(!isTooltipOpened)} + ref={buttonRef} + /> + {cloneElement(TooltipComponent, { + cloneClassName: `${styles.tooltip} ${styles[tooltipModifier]}`, + ref: tooltipRef, + })} + </> + )} + <div className={`${styles.body} ${bodyClassName}`}>{children}</div> + </fieldset> + ); +}; + +export default Fieldset; diff --git a/src/components/molecules/forms/labelled-boolean-field.fixture.tsx b/src/components/molecules/forms/labelled-boolean-field.fixture.tsx new file mode 100644 index 0000000..6b06887 --- /dev/null +++ b/src/components/molecules/forms/labelled-boolean-field.fixture.tsx @@ -0,0 +1 @@ +export const label = 'Quas et natus'; diff --git a/src/components/molecules/forms/labelled-boolean-field.module.scss b/src/components/molecules/forms/labelled-boolean-field.module.scss new file mode 100644 index 0000000..10a9eb2 --- /dev/null +++ b/src/components/molecules/forms/labelled-boolean-field.module.scss @@ -0,0 +1,15 @@ +.label { + &--visible#{&}--left { + margin-right: var(--spacing-2xs); + } + + &--visible#{&}--right { + margin-left: var(--spacing-2xs); + } +} + +.wrapper { + display: inline-flex; + flex-flow: row wrap; + align-items: center; +} diff --git a/src/components/molecules/forms/labelled-boolean-field.stories.tsx b/src/components/molecules/forms/labelled-boolean-field.stories.tsx new file mode 100644 index 0000000..6098b51 --- /dev/null +++ b/src/components/molecules/forms/labelled-boolean-field.stories.tsx @@ -0,0 +1,254 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { useState } from 'react'; +import LabelledBooleanField from './labelled-boolean-field'; +import { label } from './labelled-boolean-field.fixture'; + +/** + * LabelledBooleanField - Storybook Meta + */ +export default { + title: 'Molecules/Forms/Boolean', + component: LabelledBooleanField, + args: { + checked: false, + hidden: false, + label, + labelSize: 'small', + }, + argTypes: { + checked: { + control: { + type: null, + }, + description: 'Should the option be checked?', + type: { + name: 'boolean', + required: true, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the labelled field wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + fieldClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the field.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + hidden: { + control: { + type: 'boolean', + }, + description: 'Define if the field should be visually hidden.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + id: { + control: { + type: 'text', + }, + description: 'The option id.', + type: { + name: 'string', + required: true, + }, + }, + label: { + control: { + type: 'text', + }, + description: 'The radio 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: 'Determine the label position.', + options: ['left', 'right'], + table: { + category: 'Options', + defaultValue: { summary: 'left' }, + }, + 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 field name.', + type: { + name: 'string', + required: true, + }, + }, + onChange: { + control: { + type: null, + }, + description: 'A callback function to handle field state change.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: true, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click on field.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + type: { + control: { + type: 'select', + }, + description: 'The field type. Either checkbox or radio.', + options: ['checkbox', 'radio'], + type: { + name: 'string', + required: true, + }, + }, + value: { + control: { + type: 'text', + }, + description: 'The field value.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LabelledBooleanField>; + +const Template: ComponentStory<typeof LabelledBooleanField> = ({ + checked, + onChange: _onChange, + ...args +}) => { + const [isChecked, setIsChecked] = useState<boolean>(checked); + + return ( + <LabelledBooleanField + checked={isChecked} + onChange={() => { + setIsChecked(!isChecked); + }} + {...args} + /> + ); +}; + +/** + * Labelled Boolean Field Stories - Checkbox with left label + */ +export const CheckboxLeftLabel = Template.bind({}); +CheckboxLeftLabel.args = { + id: 'checkbox', + labelPosition: 'left', + name: 'checkbox-left-label', + type: 'checkbox', + value: 'checkbox', +}; + +/** + * Labelled Boolean Field Stories - Checkbox with right label + */ +export const CheckboxRightLabel = Template.bind({}); +CheckboxRightLabel.args = { + id: 'checkbox', + labelPosition: 'right', + name: 'checkbox-right-label', + type: 'checkbox', +}; + +/** + * Labelled Boolean Field Stories - Radio button with left label + */ +export const RadioButtonLeftLabel = Template.bind({}); +RadioButtonLeftLabel.args = { + id: 'radio', + labelPosition: 'left', + name: 'radio-left-label', + type: 'radio', + value: 'radio', +}; + +/** + * Labelled Boolean Field Stories - Radio button with right label + */ +export const RadioButtonRightLabel = Template.bind({}); +RadioButtonRightLabel.args = { + id: 'radio', + labelPosition: 'right', + name: 'radio-right-label', + type: 'radio', + value: 'radio', +}; diff --git a/src/components/molecules/forms/labelled-boolean-field.test.tsx b/src/components/molecules/forms/labelled-boolean-field.test.tsx new file mode 100644 index 0000000..55e04ea --- /dev/null +++ b/src/components/molecules/forms/labelled-boolean-field.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@test-utils'; +import LabelledBooleanField from './labelled-boolean-field'; +import { label } from './labelled-boolean-field.fixture'; + +describe('LabelledBooleanField', () => { + it('renders a labelled checkbox', () => { + render( + <LabelledBooleanField + checked={true} + id="jest-checkbox-field" + label={label} + name="jest-checkbox-field" + onChange={() => null} + type="checkbox" + value="checkbox" + /> + ); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('renders a labelled radio option', () => { + render( + <LabelledBooleanField + checked={true} + id="jest-radio-field" + label={label} + name="jest-radio-field" + onChange={() => null} + type="radio" + value="radio" + /> + ); + expect(screen.getByLabelText(label)).toBeInTheDocument(); + expect(screen.getByRole('radio')).toBeChecked(); + }); +}); diff --git a/src/components/molecules/forms/labelled-boolean-field.tsx b/src/components/molecules/forms/labelled-boolean-field.tsx new file mode 100644 index 0000000..46eb080 --- /dev/null +++ b/src/components/molecules/forms/labelled-boolean-field.tsx @@ -0,0 +1,92 @@ +import BooleanField, { + type BooleanFieldProps, +} from '@components/atoms/forms/boolean-field'; +import Label, { type LabelProps } from '@components/atoms/forms/label'; +import { FC } from 'react'; +import styles from './labelled-boolean-field.module.scss'; + +export type LabelledBooleanFieldProps = Omit< + BooleanFieldProps, + 'aria-labelledby' | 'className' +> & { + /** + * Set additional classnames to the labelled field wrapper. + */ + className?: string; + /** + * Set additional classnames to the field. + */ + fieldClassName?: LabelledBooleanFieldProps['className']; + /** + * The field label. + */ + label: LabelProps['children']; + /** + * Set additional classnames to the label. + */ + labelClassName?: LabelProps['className']; + /** + * The label position. Default: left. + */ + labelPosition?: 'left' | 'right'; + /** + * The label size. + */ + labelSize?: LabelProps['size']; +}; + +/** + * LabelledBooleanField component + * + * Render a checkbox or radio button with a label. + */ +const LabelledBooleanField: FC<LabelledBooleanFieldProps> = ({ + className = '', + fieldClassName, + hidden, + id, + label, + labelClassName, + labelPosition = 'left', + labelSize, + ...props +}) => { + const labelHiddenModifier = hidden ? 'label--hidden' : 'label--visible'; + const labelPositionModifier = `label--${labelPosition}`; + + return labelPosition === 'left' ? ( + <span className={`${styles.wrapper} ${className}`}> + <Label + className={`${styles[labelPositionModifier]} ${styles[labelHiddenModifier]} ${labelClassName}`} + htmlFor={id} + size={labelSize} + > + {label} + </Label> + <BooleanField + className={fieldClassName} + hidden={hidden} + id={id} + {...props} + /> + </span> + ) : ( + <span className={`${styles.wrapper} ${className}`}> + <BooleanField + className={fieldClassName} + hidden={hidden} + id={id} + {...props} + /> + <Label + className={`${styles[labelPositionModifier]} ${styles[labelHiddenModifier]} ${labelClassName}`} + htmlFor={id} + size={labelSize} + > + {label} + </Label> + </span> + ); +}; + +export default LabelledBooleanField; diff --git a/src/components/molecules/forms/motion-toggle.fixture.tsx b/src/components/molecules/forms/motion-toggle.fixture.tsx new file mode 100644 index 0000000..f13658a --- /dev/null +++ b/src/components/molecules/forms/motion-toggle.fixture.tsx @@ -0,0 +1 @@ +export const storageKey = 'reduced-motion'; diff --git a/src/components/molecules/forms/motion-toggle.stories.tsx b/src/components/molecules/forms/motion-toggle.stories.tsx index e9939bd..541ca8e 100644 --- a/src/components/molecules/forms/motion-toggle.stories.tsx +++ b/src/components/molecules/forms/motion-toggle.stories.tsx @@ -1,5 +1,6 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import MotionToggleComponent from './motion-toggle'; +import { storageKey } from './motion-toggle.fixture'; /** * MotionToggle - Storybook Meta @@ -8,11 +9,11 @@ export default { title: 'Molecules/Forms/Toggle', component: MotionToggleComponent, argTypes: { - className: { + bodyClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the toggle wrapper.', + description: 'Set additional classnames to the fieldset body wrapper.', table: { category: 'Styles', }, @@ -21,11 +22,22 @@ export default { required: false, }, }, - labelClassName: { + defaultValue: { + control: { + type: 'select', + }, + description: 'Set the default value.', + options: ['on', 'off'], + type: { + name: 'string', + required: true, + }, + }, + groupClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the label wrapper.', + description: 'Set additional classnames to the radio group wrapper.', table: { category: 'Styles', }, @@ -34,23 +46,26 @@ export default { required: false, }, }, - storageKey: { + legendClassName: { control: { type: 'text', }, - description: 'Set local storage key.', + description: 'Set additional classnames to the legend.', + table: { + category: 'Styles', + }, type: { name: 'string', - required: true, + required: false, }, }, - value: { + storageKey: { control: { - type: null, + type: 'text', }, - description: 'The reduce motion value.', + description: 'Set local storage key.', type: { - name: 'boolean', + name: 'string', required: true, }, }, @@ -66,5 +81,6 @@ const Template: ComponentStory<typeof MotionToggleComponent> = (args) => ( */ export const Motion = Template.bind({}); Motion.args = { - value: false, + defaultValue: 'on', + storageKey, }; diff --git a/src/components/molecules/forms/motion-toggle.test.tsx b/src/components/molecules/forms/motion-toggle.test.tsx index 4fd6b31..04c22a9 100644 --- a/src/components/molecules/forms/motion-toggle.test.tsx +++ b/src/components/molecules/forms/motion-toggle.test.tsx @@ -1,13 +1,15 @@ import { render, screen } from '@test-utils'; import MotionToggle from './motion-toggle'; +import { storageKey } from './motion-toggle.fixture'; describe('MotionToggle', () => { - it('renders a checked toggle (deactivate animations choice)', () => { - render(<MotionToggle storageKey="reduced-motion" value={true} />); + // toHaveValue received undefined. Maybe because of localStorage hook... + it('renders a toggle component', () => { + render(<MotionToggle storageKey={storageKey} defaultValue="on" />); expect( - screen.getByRole('checkbox', { - name: `Animations: On Off`, + screen.getByRole('radiogroup', { + name: /Animations:/i, }) - ).toBeChecked(); + ).toBeInTheDocument(); }); }); diff --git a/src/components/molecules/forms/motion-toggle.tsx b/src/components/molecules/forms/motion-toggle.tsx index 55ff150..ec2d950 100644 --- a/src/components/molecules/forms/motion-toggle.tsx +++ b/src/components/molecules/forms/motion-toggle.tsx @@ -1,17 +1,25 @@ -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'; +import RadioGroup, { + type RadioGroupCallback, + type RadioGroupCallbackProps, + type RadioGroupOption, + type RadioGroupProps, +} from './radio-group'; + +export type MotionToggleValue = 'on' | 'off'; export type MotionToggleProps = Pick< - ToggleProps, - 'className' | 'labelClassName' | 'value' + RadioGroupProps, + 'bodyClassName' | 'groupClassName' | 'legendClassName' > & { /** + * True if motion should be reduced by default. + */ + defaultValue: 'on' | 'off'; + /** * The local storage key to save preference. */ storageKey: string; @@ -23,14 +31,14 @@ export type MotionToggleProps = Pick< * Render a Toggle component to set reduce motion. */ const MotionToggle: FC<MotionToggleProps> = ({ + defaultValue, storageKey, - value, ...props }) => { const intl = useIntl(); const { value: isReduced, setValue: setIsReduced } = useLocalStorage<boolean>( storageKey, - value + defaultValue === 'on' ? false : true ); useAttributes({ element: document.documentElement || undefined, @@ -53,20 +61,56 @@ const MotionToggle: FC<MotionToggleProps> = ({ description: 'MotionToggle: deactivate reduce motion label', id: 'pWKyyR', }); - const reduceMotionChoices: ToggleChoices = { - left: onLabel, - right: offLabel, + + const options: RadioGroupOption[] = [ + { + id: 'reduced-motion-on', + label: onLabel, + name: 'reduced-motion', + value: 'on', + }, + { + id: 'reduced-motion-off', + label: offLabel, + name: 'reduced-motion', + value: 'off', + }, + ]; + + /** + * Update the current setting. + * + * @param {string} newValue - A boolean as string. + */ + const updateSetting = (newValue: MotionToggleValue) => { + setIsReduced(newValue === 'on' ? false : true); + }; + + /** + * Handle change events. + * + * @param {RadioGroupCallbackProps} props - An object with choices. + */ + const handleChange: RadioGroupCallback = ({ + choices, + updateChoice, + }: RadioGroupCallbackProps) => { + if (choices.new === choices.prev) { + const newChoice = choices.new === 'on' ? 'off' : 'on'; + updateChoice(newChoice); + updateSetting(newChoice); + } else { + updateSetting(choices.new as MotionToggleValue); + } }; return ( - <Toggle - id="reduce-motion-settings" - name="reduce-motion-settings" - label={reduceMotionLabel} - labelSize="medium" - choices={reduceMotionChoices} - value={isReduced} - setValue={setIsReduced} + <RadioGroup + initialChoice={defaultValue} + kind="toggle" + legend={reduceMotionLabel} + onChange={handleChange} + options={options} {...props} /> ); diff --git a/src/components/molecules/forms/prism-theme-toggle.stories.tsx b/src/components/molecules/forms/prism-theme-toggle.stories.tsx index 6a88e51..86f9773 100644 --- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx @@ -8,11 +8,11 @@ export default { title: 'Molecules/Forms/Toggle', component: PrismThemeToggle, argTypes: { - className: { + bodyClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the toggle wrapper.', + description: 'Set additional classnames to the fieldset body wrapper.', table: { category: 'Styles', }, @@ -21,11 +21,24 @@ export default { required: false, }, }, - labelClassName: { + groupClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the label wrapper.', + description: 'Set additional classnames to the radio group wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legendClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the legend.', table: { category: 'Styles', }, diff --git a/src/components/molecules/forms/prism-theme-toggle.test.tsx b/src/components/molecules/forms/prism-theme-toggle.test.tsx index c9d7894..91e8e2e 100644 --- a/src/components/molecules/forms/prism-theme-toggle.test.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.test.tsx @@ -5,8 +5,8 @@ describe('PrismThemeToggle', () => { it('renders a toggle component', () => { render(<PrismThemeToggle />); expect( - screen.getByRole('checkbox', { - name: `Code blocks: Light theme Dark theme`, + screen.getByRole('radiogroup', { + name: /Code blocks:/i, }) ).toBeInTheDocument(); }); diff --git a/src/components/molecules/forms/prism-theme-toggle.tsx b/src/components/molecules/forms/prism-theme-toggle.tsx index e0b795f..7bf5b7c 100644 --- a/src/components/molecules/forms/prism-theme-toggle.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.tsx @@ -1,16 +1,18 @@ 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 { type PrismTheme, usePrismTheme } from '@utils/providers/prism-theme'; import { FC } from 'react'; import { useIntl } from 'react-intl'; +import RadioGroup, { + type RadioGroupCallback, + type RadioGroupCallbackProps, + type RadioGroupOption, + type RadioGroupProps, +} from './radio-group'; export type PrismThemeToggleProps = Pick< - ToggleProps, - 'className' | 'labelClassName' + RadioGroupProps, + 'bodyClassName' | 'groupClassName' | 'legendClassName' >; /** @@ -18,7 +20,7 @@ export type PrismThemeToggleProps = Pick< * * Render a Toggle component to set code blocks theme. */ -const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => { +const PrismThemeToggle: FC<PrismThemeToggleProps> = (props) => { const intl = useIntl(); const { theme, setTheme, resolvedTheme } = usePrismTheme(); @@ -27,16 +29,36 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => { * * @returns {boolean} True if it is dark theme. */ - const isDarkTheme = (): boolean => { - if (theme === 'system') return resolvedTheme === 'dark'; - return theme === 'dark'; + const isDarkTheme = (prismTheme?: PrismTheme): boolean => { + if (prismTheme === 'system') return resolvedTheme === 'dark'; + return prismTheme === 'dark'; }; /** * Update the theme. + * + * @param {string} newTheme - A theme name. + */ + const updateTheme = (newTheme: string) => { + setTheme(newTheme === 'light' ? 'light' : 'dark'); + }; + + /** + * Handle change events. + * + * @param {RadioGroupCallbackProps} props - An object with choices. */ - const updateTheme = () => { - setTheme(isDarkTheme() ? 'light' : 'dark'); + const handleChange: RadioGroupCallback = ({ + choices, + updateChoice, + }: RadioGroupCallbackProps) => { + if (choices.new === choices.prev) { + const newTheme = choices.new === 'light' ? 'dark' : 'light'; + updateChoice(newTheme); + updateTheme(newTheme); + } else { + updateTheme(choices.new); + } }; const themeLabel = intl.formatMessage({ @@ -54,20 +76,29 @@ const PrismThemeToggle: FC<PrismThemeToggleProps> = ({ ...props }) => { description: 'PrismThemeToggle: dark theme label', id: 'og/zWL', }); - const themeChoices: ToggleChoices = { - left: <Sun title={lightThemeLabel} />, - right: <Moon title={darkThemeLabel} />, - }; + + const options: RadioGroupOption[] = [ + { + id: 'code-blocks-light', + label: <Sun title={lightThemeLabel} />, + name: 'code-blocks', + value: 'light', + }, + { + id: 'code-blocks-dark', + label: <Moon title={darkThemeLabel} />, + name: 'code-blocks', + value: 'dark', + }, + ]; return ( - <Toggle - id="prism-theme-settings" - name="prism-theme-settings" - label={themeLabel} - labelSize="medium" - choices={themeChoices} - value={isDarkTheme()} - setValue={updateTheme} + <RadioGroup + initialChoice={isDarkTheme(theme) ? 'dark' : 'light'} + kind="toggle" + legend={themeLabel} + onChange={handleChange} + options={options} {...props} /> ); diff --git a/src/components/molecules/forms/radio-group.fixture.tsx b/src/components/molecules/forms/radio-group.fixture.tsx new file mode 100644 index 0000000..686467c --- /dev/null +++ b/src/components/molecules/forms/radio-group.fixture.tsx @@ -0,0 +1,47 @@ +import { RadioGroupOption } from './radio-group'; + +export const getOptions = (name: string = 'group1') => { + const value1 = 'option1'; + const value2 = 'option2'; + const value3 = 'option3'; + const value4 = 'option4'; + const value5 = 'option5'; + + const options: RadioGroupOption[] = [ + { + id: `${name}-${value1}`, + name: name, + label: 'Option 1', + value: value1, + }, + { + id: `${name}-${value2}`, + name: name, + label: 'Option 2', + value: value2, + }, + { + id: `${name}-${value3}`, + name: name, + label: 'Option 3', + value: value3, + }, + { + id: `${name}-${value4}`, + name: name, + label: 'Option 4', + value: value4, + }, + { + id: `${name}-${value5}`, + name: name, + label: 'Option 5', + value: value5, + }, + ]; + + return options; +}; + +export const initialChoice = 'option2'; +export const legend = 'Options:'; diff --git a/src/components/molecules/forms/radio-group.module.scss b/src/components/molecules/forms/radio-group.module.scss new file mode 100644 index 0000000..0bd34b9 --- /dev/null +++ b/src/components/molecules/forms/radio-group.module.scss @@ -0,0 +1,112 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; + +.wrapper { + &--inline#{&}--regular { + .option:first-of-type { + margin-left: var(--spacing-2xs); + } + } + + &--regular { + .option { + &:not(:last-of-type) { + margin-right: var(--spacing-xs); + } + } + } +} + +.toggle { + display: inline-flex; + flex-flow: row wrap; + align-items: center; + width: fit-content; + position: relative; + background: var(--color-shadow-light); + border: fun.convert-px(2) solid var(--color-primary); + border-radius: fun.convert-px(32); + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(1) + var(--color-shadow-dark), + inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow); + + .label { + display: flex; + align-items: center; + min-height: 5ex; + padding: fun.convert-px(6) var(--spacing-2xs); + border-top: fun.convert-px(2) solid var(--color-border); + border-bottom: fun.convert-px(2) solid var(--color-border); + transition: all 0.15s linear 0s; + + @include mix.pointer("fine") { + min-height: 3ex; + } + } + + &:focus-within { + outline: fun.convert-px(2) solid var(--color-primary-light); + } + + .option:first-of-type { + .label { + border-left: fun.convert-px(2) solid var(--color-border); + border-top-left-radius: fun.convert-px(32); + border-bottom-left-radius: fun.convert-px(32); + } + } + + .option:last-of-type { + .label { + border-right: fun.convert-px(2) solid var(--color-border); + border-top-right-radius: fun.convert-px(32); + border-bottom-right-radius: fun.convert-px(32); + } + } + + .radio { + &:checked + .label { + background: var(--color-primary); + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2) + var(--color-primary-dark), + inset 0 0 fun.convert-px(3) fun.convert-px(2) + var(--color-primary-darker); + color: var(--color-fg-inverted); + + svg { + fill: var(--color-fg-inverted); + stroke: var(--color-fg-inverted); + } + } + + &:not(:checked) + .label { + svg { + fill: var(--color-primary-darker); + } + } + + &:checked + .label:hover { + background: var(--color-primary-lighter); + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2) + var(--color-primary-light), + inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary); + } + + &:not(:checked) + .label:hover { + background: var(--color-shadow-light); + box-shadow: inset 0 0 0 fun.convert-px(1) var(--color-shadow-dark), + inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-shadow); + } + + &:not(:checked):focus + .label { + background: var(--color-shadow-light); + } + + &:checked:focus + .label { + background: var(--color-primary-lighter); + box-shadow: inset 0 0 fun.convert-px(1) fun.convert-px(2) + var(--color-primary-light), + inset 0 0 fun.convert-px(3) fun.convert-px(2) var(--color-primary); + } + } +} diff --git a/src/components/molecules/forms/radio-group.stories.tsx b/src/components/molecules/forms/radio-group.stories.tsx new file mode 100644 index 0000000..ad1bd6d --- /dev/null +++ b/src/components/molecules/forms/radio-group.stories.tsx @@ -0,0 +1,272 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import RadioGroup from './radio-group'; +import { getOptions, initialChoice, legend } from './radio-group.fixture'; + +/** + * RadioGroup - Storybook Meta + */ +export default { + title: 'Molecules/Forms/RadioGroup', + component: RadioGroup, + args: { + kind: 'regular', + labelSize: 'small', + }, + argTypes: { + bodyClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the fieldset body wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the fieldset.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + groupClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the radio group wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + initialChoice: { + control: { + type: 'text', + }, + description: 'The default selected option id.', + type: { + name: 'string', + required: true, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The radio group kind.', + options: ['regular', 'toggle'], + table: { + category: 'Options', + defaultValue: { summary: 'regular' }, + }, + type: { + name: 'string', + required: false, + }, + }, + labelPosition: { + control: { + type: 'select', + }, + description: 'Determine the label position.', + options: ['left', 'right'], + table: { + category: 'Options', + defaultValue: { summary: 'left' }, + }, + type: { + name: 'string', + required: false, + }, + }, + labelSize: { + control: { + type: 'select', + }, + description: 'The label size.', + options: ['medium', 'small'], + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + legend: { + control: { + type: 'text', + }, + description: 'The fieldset legend.', + type: { + name: 'string', + required: true, + }, + }, + legendClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the legend.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legendPosition: { + control: { + type: 'select', + }, + description: 'Determine the legend position.', + options: ['inline', 'stacked'], + table: { + category: 'Options', + defaultValue: { summary: 'inline' }, + }, + type: { + name: 'string', + required: false, + }, + }, + onChange: { + control: { + type: null, + }, + description: 'A callback function to handle selected option change.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + onClick: { + control: { + type: null, + }, + description: 'A callback function to handle click on a choice.', + table: { + category: 'Events', + }, + type: { + name: 'function', + required: false, + }, + }, + optionClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the option wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + options: { + description: 'An array of radio option object.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + Tooltip: { + control: { + type: null, + }, + description: 'Add an optional tooltip.', + table: { + category: 'Options', + }, + type: { + name: 'function', + required: false, + }, + }, + }, +} as ComponentMeta<typeof RadioGroup>; + +const Template: ComponentStory<typeof RadioGroup> = (args) => ( + <RadioGroup {...args} /> +); + +/** + * Radio Group Stories - Inlined legend & left label + */ +export const InlinedLegendLeftLabel = Template.bind({}); +InlinedLegendLeftLabel.args = { + initialChoice: initialChoice, + labelPosition: 'left', + legend: legend, + legendPosition: 'inline', + options: getOptions('group1'), +}; + +/** + * Radio Group Stories - Inlined legend & left label + */ +export const InlinedLegendRightLabel = Template.bind({}); +InlinedLegendRightLabel.args = { + initialChoice: initialChoice, + labelPosition: 'right', + legend: legend, + legendPosition: 'inline', + options: getOptions('group2'), +}; + +/** + * Radio Group Stories - Stacked legend & left label + */ +export const StackedLegendLeftLabel = Template.bind({}); +StackedLegendLeftLabel.args = { + initialChoice: initialChoice, + labelPosition: 'left', + legend: legend, + legendPosition: 'stacked', + options: getOptions('group3'), +}; + +/** + * Radio Group Stories - Stacked legend & left label + */ +export const StackedLegendRightLabel = Template.bind({}); +StackedLegendRightLabel.args = { + initialChoice: initialChoice, + labelPosition: 'right', + legend: legend, + legendPosition: 'stacked', + options: getOptions('group4'), +}; + +/** + * Radio Group Stories - Toggle + */ +export const Toggle = Template.bind({}); +Toggle.args = { + initialChoice: initialChoice, + kind: 'toggle', + labelPosition: 'right', + legend: legend, + options: getOptions('group5'), +}; diff --git a/src/components/molecules/forms/radio-group.test.tsx b/src/components/molecules/forms/radio-group.test.tsx new file mode 100644 index 0000000..8171a49 --- /dev/null +++ b/src/components/molecules/forms/radio-group.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@test-utils'; +import RadioGroup from './radio-group'; +import { getOptions, initialChoice, legend } from './radio-group.fixture'; + +describe('RadioGroup', () => { + it('renders a legend', () => { + render( + <RadioGroup + initialChoice={initialChoice} + legend={legend} + options={getOptions()} + /> + ); + expect(screen.findByRole('radiogroup', { name: legend })).toBeDefined(); + }); + + it('renders the correct number of radio', () => { + const options = getOptions(); + + render( + <RadioGroup + initialChoice={initialChoice} + legend={legend} + options={options} + /> + ); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(options.length); + }); +}); diff --git a/src/components/molecules/forms/radio-group.tsx b/src/components/molecules/forms/radio-group.tsx new file mode 100644 index 0000000..64bdaa0 --- /dev/null +++ b/src/components/molecules/forms/radio-group.tsx @@ -0,0 +1,148 @@ +import Fieldset, { + type FieldsetProps, +} from '@components/molecules/forms/fieldset'; +import useStateChange from '@utils/hooks/use-state-change'; +import { ChangeEvent, FC, MouseEvent, SetStateAction, useState } from 'react'; +import LabelledBooleanField, { + type LabelledBooleanFieldProps, +} from './labelled-boolean-field'; +import styles from './radio-group.module.scss'; + +export type RadioGroupCallbackProps = { + choices: { + new: string; + prev: string; + }; + updateChoice: (value: SetStateAction<string>) => void; +}; + +export type RadioGroupCallback = (props: RadioGroupCallbackProps) => void; + +export type RadioGroupOption = Pick< + LabelledBooleanFieldProps, + 'id' | 'label' | 'name' | 'value' +>; + +export type RadioGroupProps = Pick< + FieldsetProps, + 'bodyClassName' | 'className' | 'legend' | 'legendClassName' | 'Tooltip' +> & + Pick<LabelledBooleanFieldProps, 'labelPosition' | 'labelSize'> & { + /** + * Set additional classnames to the radio group wrapper when kind is toggle. + */ + groupClassName?: string; + /** + * The default option value. + */ + initialChoice: string; + /** + * The radio group kind. Default: regular. + */ + kind?: 'regular' | 'toggle'; + /** + * The legend position. Default: inline. + */ + legendPosition?: FieldsetProps['legendPosition']; + /** + * A callback function to execute when choice is changed. + */ + onChange?: RadioGroupCallback; + /** + * A callback function to execute when clicking on a choice. + */ + onClick?: RadioGroupCallback; + /** + * Set additional classnames to the labelled field wrapper. + */ + optionClassName?: string; + /** + * The options. + */ + options: RadioGroupOption[]; + }; + +/** + * RadioGroup component + * + * Render a group of labelled radio buttons. + */ +const RadioGroup: FC<RadioGroupProps> = ({ + className, + groupClassName = '', + initialChoice, + kind = 'regular', + labelPosition, + labelSize, + legendPosition = 'inline', + onChange, + optionClassName = '', + options, + ...props +}) => { + const [selectedChoice, setSelectedChoice] = + useStateChange<string>(initialChoice); + const isToggle = kind === 'toggle'; + const alignmentModifier = `wrapper--${legendPosition}`; + const toggleModifier = isToggle ? 'wrapper--toggle' : 'wrapper--regular'; + + /** + * Update the selected choice on click or change event. + */ + const updateChoice = ( + e: + | ChangeEvent<HTMLInputElement> + | MouseEvent<HTMLInputElement, globalThis.MouseEvent> + ) => { + const input = e.target as HTMLInputElement; + onChange && + onChange({ + choices: { new: input.value, prev: selectedChoice }, + updateChoice: setSelectedChoice, + }); + if (e.type === 'change') setSelectedChoice(input.value); + }; + + /** + * Retrieve an array of radio buttons. + * + * @returns {JSX.Element[]} The radio buttons. + */ + const getOptions = (): JSX.Element[] => { + return options.map((option) => ( + <LabelledBooleanField + key={option.id} + checked={selectedChoice === option.value} + className={`${styles.option} ${optionClassName}`} + fieldClassName={styles.radio} + hidden={isToggle} + labelClassName={styles.label} + labelPosition={kind === 'toggle' ? 'right' : labelPosition} + labelSize={labelSize} + onChange={updateChoice} + onClick={updateChoice} + type="radio" + {...option} + /> + )); + }; + + return ( + <Fieldset + className={`${styles.wrapper} ${styles[alignmentModifier]} ${styles[toggleModifier]} ${className}`} + legendPosition={legendPosition} + role="radiogroup" + {...props} + > + {isToggle ? ( + <span className={`${styles.toggle} ${groupClassName}`}> + {getOptions()} + </span> + ) : ( + getOptions() + )} + </Fieldset> + ); +}; + +export default RadioGroup; diff --git a/src/components/molecules/forms/select-with-tooltip.stories.tsx b/src/components/molecules/forms/select-with-tooltip.stories.tsx deleted file mode 100644 index d6206a9..0000000 --- a/src/components/molecules/forms/select-with-tooltip.stories.tsx +++ /dev/null @@ -1,223 +0,0 @@ -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: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the select wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - 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 deleted file mode 100644 index 7a423f5..0000000 --- a/src/components/molecules/forms/select-with-tooltip.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 46075c2..0000000 --- a/src/components/molecules/forms/select-with-tooltip.tsx +++ /dev/null @@ -1,78 +0,0 @@ -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 select wrapper. - */ - className?: string; - /** - * 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> = ({ - className = '', - content, - id, - title, - 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} ${className}`}> - <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 index a7bebb4..ff1034d 100644 --- a/src/components/molecules/forms/theme-toggle.stories.tsx +++ b/src/components/molecules/forms/theme-toggle.stories.tsx @@ -8,11 +8,11 @@ export default { title: 'Molecules/Forms/Toggle', component: ThemeToggle, argTypes: { - className: { + bodyClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the toggle wrapper.', + description: 'Set additional classnames to the fieldset body wrapper.', table: { category: 'Styles', }, @@ -21,11 +21,24 @@ export default { required: false, }, }, - labelClassName: { + groupClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the label wrapper.', + description: 'Set additional classnames to the radio group wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + legendClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the legend.', table: { category: 'Styles', }, diff --git a/src/components/molecules/forms/theme-toggle.test.tsx b/src/components/molecules/forms/theme-toggle.test.tsx index 0600c5e..ed8b312 100644 --- a/src/components/molecules/forms/theme-toggle.test.tsx +++ b/src/components/molecules/forms/theme-toggle.test.tsx @@ -5,8 +5,8 @@ describe('ThemeToggle', () => { it('renders a toggle component', () => { render(<ThemeToggle />); expect( - screen.getByRole('checkbox', { - name: `Theme: Light theme Dark theme`, + screen.getByRole('radiogroup', { + name: /Theme:/i, }) ).toBeInTheDocument(); }); diff --git a/src/components/molecules/forms/theme-toggle.tsx b/src/components/molecules/forms/theme-toggle.tsx index e9dd5e4..b796b27 100644 --- a/src/components/molecules/forms/theme-toggle.tsx +++ b/src/components/molecules/forms/theme-toggle.tsx @@ -1,16 +1,18 @@ 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'; +import RadioGroup, { + type RadioGroupCallback, + type RadioGroupCallbackProps, + type RadioGroupOption, + type RadioGroupProps, +} from './radio-group'; export type ThemeToggleProps = Pick< - ToggleProps, - 'className' | 'labelClassName' + RadioGroupProps, + 'bodyClassName' | 'groupClassName' | 'legendClassName' >; /** @@ -18,16 +20,36 @@ export type ThemeToggleProps = Pick< * * Render a Toggle component to set theme. */ -const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => { +const ThemeToggle: FC<ThemeToggleProps> = (props) => { const intl = useIntl(); const { resolvedTheme, setTheme } = useTheme(); const isDarkTheme = resolvedTheme === 'dark'; /** * Update the theme. + * + * @param {string} theme - A theme name. */ - const updateTheme = () => { - setTheme(isDarkTheme ? 'light' : 'dark'); + const updateTheme = (theme: string) => { + setTheme(theme === 'light' ? 'light' : 'dark'); + }; + + /** + * Handle change events. + * + * @param {RadioGroupCallbackProps} props - An object with choices. + */ + const handleChange: RadioGroupCallback = ({ + choices, + updateChoice, + }: RadioGroupCallbackProps) => { + if (choices.new === choices.prev) { + const newTheme = choices.new === 'light' ? 'dark' : 'light'; + updateChoice(newTheme); + updateTheme(newTheme); + } else { + updateTheme(choices.new); + } }; const themeLabel = intl.formatMessage({ @@ -45,20 +67,29 @@ const ThemeToggle: FC<ThemeToggleProps> = ({ ...props }) => { description: 'ThemeToggle: dark theme label', id: '2QwvtS', }); - const themeChoices: ToggleChoices = { - left: <Sun title={lightThemeLabel} />, - right: <Moon title={darkThemeLabel} />, - }; + + const options: RadioGroupOption[] = [ + { + id: 'theme-light', + label: <Sun title={lightThemeLabel} />, + name: 'theme', + value: 'light', + }, + { + id: 'theme-dark', + label: <Moon title={darkThemeLabel} />, + name: 'theme', + value: 'dark', + }, + ]; return ( - <Toggle - id="theme-settings" - name="theme-settings" - label={themeLabel} - labelSize="medium" - choices={themeChoices} - value={isDarkTheme} - setValue={updateTheme} + <RadioGroup + initialChoice={isDarkTheme ? 'dark' : 'light'} + kind="toggle" + legend={themeLabel} + onChange={handleChange} + options={options} {...props} /> ); diff --git a/src/components/molecules/forms/toggle.module.scss b/src/components/molecules/forms/toggle.module.scss deleted file mode 100644 index 2e8a49f..0000000 --- a/src/components/molecules/forms/toggle.module.scss +++ /dev/null @@ -1,75 +0,0 @@ -@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 deleted file mode 100644 index f1b8296..0000000 --- a/src/components/molecules/forms/toggle.stories.tsx +++ /dev/null @@ -1,134 +0,0 @@ -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: {}, - }, - }, - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the toggle wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - 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 deleted file mode 100644 index fb97adc..0000000 --- a/src/components/molecules/forms/toggle.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 0fac45c..0000000 --- a/src/components/molecules/forms/toggle.tsx +++ /dev/null @@ -1,87 +0,0 @@ -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; - /** - * Set additional classnames to the toggle wrapper. - */ - className?: string; - /** - * 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, - className = '', - 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} ${className}`} - > - <span className={`${styles.title} ${labelClassName}`}>{label}</span> - {choices.left} - <span className={styles.toggle}></span> - {choices.right} - </Label> - </> - ); -}; - -export default Toggle; diff --git a/src/components/molecules/modals/tooltip.fixture.tsx b/src/components/molecules/modals/tooltip.fixture.tsx new file mode 100644 index 0000000..5489f08 --- /dev/null +++ b/src/components/molecules/modals/tooltip.fixture.tsx @@ -0,0 +1,4 @@ +export const title = 'Illum eum at'; +export const content = + 'Non accusantium ad. Est et impedit iste animi voluptas cum accusamus accusantium. Repellat ut sint pariatur cumque cupiditate. Animi occaecati odio ut debitis ipsam similique. Repudiandae aut earum occaecati consequatur laborum ut nobis iusto. Adipisci laboriosam id.'; +export const icon = '?'; diff --git a/src/components/molecules/modals/tooltip.stories.tsx b/src/components/molecules/modals/tooltip.stories.tsx index 06a4855..a3dfa9f 100644 --- a/src/components/molecules/modals/tooltip.stories.tsx +++ b/src/components/molecules/modals/tooltip.stories.tsx @@ -1,5 +1,6 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import Tooltip from './tooltip'; +import { content, icon, title } from './tooltip.fixture'; /** * Tooltip - Storybook Meta @@ -21,6 +22,20 @@ export default { required: false, }, }, + cloneClassName: { + control: { + type: 'text', + }, + description: + 'Set additional classnames to the tooltip when using cloneElement.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, content: { control: { type: 'text', @@ -63,8 +78,7 @@ const Template: ComponentStory<typeof Tooltip> = (args) => ( */ export const Help = Template.bind({}); Help.args = { - content: - 'Minima tempora fuga omnis ratione doloribus ut. Totam ea vitae consequatur. Fuga hic ipsum. In non debitis ex assumenda ut dicta. Sit ut maxime eligendi est.', - icon: '?', - title: 'Laborum enim vero', + content, + icon, + title, }; diff --git a/src/components/molecules/modals/tooltip.test.tsx b/src/components/molecules/modals/tooltip.test.tsx index 24f20d8..d00b4b1 100644 --- a/src/components/molecules/modals/tooltip.test.tsx +++ b/src/components/molecules/modals/tooltip.test.tsx @@ -1,10 +1,6 @@ import { render, screen } from '@test-utils'; import Tooltip from './tooltip'; - -const title = 'Illum eum at'; -const content = - 'Non accusantium ad. Est et impedit iste animi voluptas cum accusamus accusantium. Repellat ut sint pariatur cumque cupiditate. Animi occaecati odio ut debitis ipsam similique. Repudiandae aut earum occaecati consequatur laborum ut nobis iusto. Adipisci laboriosam id.'; -const icon = '?'; +import { content, icon, title } from './tooltip.fixture'; describe('Tooltip', () => { it('renders a title', () => { diff --git a/src/components/molecules/modals/tooltip.tsx b/src/components/molecules/modals/tooltip.tsx index efb3009..9801393 100644 --- a/src/components/molecules/modals/tooltip.tsx +++ b/src/components/molecules/modals/tooltip.tsx @@ -8,6 +8,10 @@ export type TooltipProps = { */ className?: string; /** + * Set more additional classnames to the tooltip wrapper. Required when using React.cloneElement. + */ + cloneClassName?: string; + /** * The tooltip body. */ content: string | string[]; @@ -27,7 +31,7 @@ export type TooltipProps = { * Render a tooltip modal. */ const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = ( - { className = '', content, icon, title }, + { cloneClassName = '', className = '', content, icon, title }, ref ) => { /** @@ -43,7 +47,10 @@ const Tooltip: ForwardRefRenderFunction<HTMLDivElement, TooltipProps> = ( }; return ( - <div className={`${styles.wrapper} ${className}`} ref={ref}> + <div + className={`${styles.wrapper} ${cloneClassName} ${className}`} + ref={ref} + > <div className={styles.title}> <span className={styles.icon}>{icon}</span> {title} diff --git a/src/components/organisms/forms/settings-form.module.scss b/src/components/organisms/forms/settings-form.module.scss index a05c60c..647f1b5 100644 --- a/src/components/organisms/forms/settings-form.module.scss +++ b/src/components/organisms/forms/settings-form.module.scss @@ -1,26 +1,81 @@ @use "@styles/abstracts/mixins" as mix; -.label { - margin-right: auto; -} +.wrapper { + display: flex; + flex-flow: row wrap; + align-items: flex-start; + align-content: flex-start; -.setting, -.label--select { @include mix.media("screen") { - @include mix.dimensions(null, "2xs") { + @include mix.dimensions(null, "2xs", "height") { + column-gap: var(--spacing-lg); + row-gap: var(--spacing-xs); font-size: var(--font-size-sm); } + } - @include mix.dimensions(null, "2xs", "height") { - font-size: var(--font-size-sm); + .label { + @include mix.media("screen") { + @include mix.dimensions(null, "2xs", "height") { + margin: 0 auto; + float: none; + font-size: var(--font-size-sm); + } + + @include mix.dimensions(null, "2xs") { + //font-size: var(--font-size-sm); + } + } + } + + .tooltip { + top: unset; + bottom: calc(100% + var(--spacing-2xs)); + font-size: var(--font-size-sm); + transform-origin: bottom center; + + @include mix.media("screen") { + @include mix.dimensions(null, "2xs", "height") { + width: 250%; + transform-origin: bottom left; + } + + @include mix.dimensions("sm") { + font-size: var(--font-size-md); + } } } } .items { + margin: var(--spacing-2xs) 0; + + @include mix.media("screen") { + @include mix.dimensions(null, "2xs", "height") { + display: flex; + flex-flow: column wrap; + max-width: fit-content; + margin: 0; + } + } +} + +.group { + margin-left: auto; + + @include mix.media("screen") { + @include mix.dimensions(null, "2xs", "height") { + margin: auto; + } + } +} + +.fieldset__body { + margin-left: auto; + @include mix.media("screen") { @include mix.dimensions(null, "2xs", "height") { - margin: var(--spacing-2xs) 0; + margin: 0 auto; } } } diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx index 70e1844..de9f769 100644 --- a/src/components/organisms/forms/settings-form.stories.tsx +++ b/src/components/organisms/forms/settings-form.stories.tsx @@ -1,3 +1,5 @@ +import { storageKey as ackeeStorageKey } from '@components/molecules/forms/ackee-toggle.fixture'; +import { storageKey as motionStorageKey } from '@components/molecules/forms/motion-toggle.fixture'; import { ComponentMeta, ComponentStory } from '@storybook/react'; import SettingsForm from './settings-form'; @@ -65,3 +67,7 @@ const Template: ComponentStory<typeof SettingsForm> = (args) => ( * Form Stories - Settings */ export const Settings = Template.bind({}); +Settings.args = { + ackeeStorageKey, + motionStorageKey, +}; diff --git a/src/components/organisms/forms/settings-form.test.tsx b/src/components/organisms/forms/settings-form.test.tsx index 43d546e..584261d 100644 --- a/src/components/organisms/forms/settings-form.test.tsx +++ b/src/components/organisms/forms/settings-form.test.tsx @@ -1,9 +1,8 @@ +import { storageKey as ackeeStorageKey } from '@components/molecules/forms/ackee-toggle.fixture'; +import { storageKey as motionStorageKey } from '@components/molecules/forms/motion-toggle.fixture'; import { render, screen } from '@test-utils'; import SettingsForm from './settings-form'; -const ackeeStorageKey = 'ackee-tracking'; -const motionStorageKey = 'reduce-motion'; - describe('SettingsForm', () => { it('renders a form', () => { render( @@ -17,7 +16,7 @@ describe('SettingsForm', () => { ).toBeInTheDocument(); }); - it('renders a theme toggle setting', () => { + it('renders a theme setting', () => { render( <SettingsForm ackeeStorageKey={ackeeStorageKey} @@ -25,11 +24,11 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Theme:/i }) + screen.getByRole('radiogroup', { name: /^Theme:/i }) ).toBeInTheDocument(); }); - it('renders a code blocks toggle setting', () => { + it('renders a code blocks setting', () => { render( <SettingsForm ackeeStorageKey={ackeeStorageKey} @@ -37,7 +36,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Code blocks:/i }) + screen.getByRole('radiogroup', { name: /^Code blocks:/i }) ).toBeInTheDocument(); }); @@ -49,7 +48,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Animations:/i }) + screen.getByRole('radiogroup', { name: /^Animations:/i }) ).toBeInTheDocument(); }); @@ -61,7 +60,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('combobox', { name: /^Tracking:/i }) + screen.getByRole('radiogroup', { name: /^Tracking:/i }) ).toBeInTheDocument(); }); }); diff --git a/src/components/organisms/forms/settings-form.tsx b/src/components/organisms/forms/settings-form.tsx index 9c2cd2c..5d915a8 100644 --- a/src/components/organisms/forms/settings-form.tsx +++ b/src/components/organisms/forms/settings-form.tsx @@ -1,9 +1,9 @@ import Form from '@components/atoms/forms/form'; -import AckeeSelect, { - type AckeeSelectProps, -} from '@components/molecules/forms/ackee-select'; +import AckeeToggle, { + type AckeeToggleProps, +} from '@components/molecules/forms/ackee-toggle'; import MotionToggle, { - MotionToggleProps, + type MotionToggleProps, } from '@components/molecules/forms/motion-toggle'; import PrismThemeToggle from '@components/molecules/forms/prism-theme-toggle'; import ThemeToggle from '@components/molecules/forms/theme-toggle'; @@ -11,11 +11,11 @@ import { FC } from 'react'; import { useIntl } from 'react-intl'; import styles from './settings-form.module.scss'; -export type SettingsFormProps = Pick<AckeeSelectProps, 'tooltipClassName'> & { +export type SettingsFormProps = Pick<AckeeToggleProps, 'tooltipClassName'> & { /** * The local storage key for Ackee settings. */ - ackeeStorageKey: AckeeSelectProps['storageKey']; + ackeeStorageKey: AckeeToggleProps['storageKey']; /** * The local storage key for Reduce motion settings. */ @@ -37,26 +37,34 @@ const SettingsForm: FC<SettingsFormProps> = ({ return ( <Form aria-label={ariaLabel} + className={styles.wrapper} itemsClassName={styles.items} onSubmit={() => null} > - <ThemeToggle className={styles.setting} labelClassName={styles.label} /> + <ThemeToggle + bodyClassName={styles.fieldset__body} + groupClassName={styles.group} + legendClassName={styles.label} + /> <PrismThemeToggle - className={styles.setting} - labelClassName={styles.label} + bodyClassName={styles.fieldset__body} + groupClassName={styles.group} + legendClassName={styles.label} /> <MotionToggle - className={styles.setting} - labelClassName={styles.label} + defaultValue="on" + bodyClassName={styles.fieldset__body} + groupClassName={styles.group} + legendClassName={styles.label} storageKey={motionStorageKey} - value={false} /> - <AckeeSelect - className={styles.setting} - initialValue="full" - labelClassName={`${styles.label} ${styles['label--select']}`} - tooltipClassName={tooltipClassName} + <AckeeToggle + defaultValue="full" + bodyClassName={styles.fieldset__body} + groupClassName={`${styles.group} ${styles['group--ackee']}`} + legendClassName={`${styles.label} ${styles['label--ackee']}`} storageKey={ackeeStorageKey} + tooltipClassName={`${styles.tooltip} ${tooltipClassName}`} /> </Form> ); diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx index 7f57177..97846b1 100644 --- a/src/components/organisms/layout/no-results.test.tsx +++ b/src/components/organisms/layout/no-results.test.tsx @@ -4,7 +4,7 @@ import NoResults from './no-results'; describe('NoResults', () => { it('renders a no results text', () => { render(<NoResults searchPage="#" />); - expect(screen.getByText(/No results/gi)).toBeInTheDocument(); + expect(screen.getByText(/No results/i)).toBeInTheDocument(); }); it('renders a search form', () => { diff --git a/src/components/organisms/modals/settings-modal.module.scss b/src/components/organisms/modals/settings-modal.module.scss index a6a2077..70b786d 100644 --- a/src/components/organisms/modals/settings-modal.module.scss +++ b/src/components/organisms/modals/settings-modal.module.scss @@ -1,11 +1 @@ -@use "@styles/abstracts/mixins" as mix; - -.label { - margin-right: auto; - - @include mix.media("screen") { - @include mix.dimensions(null, "2xs", "height") { - font-size: var(--font-size-sm); - } - } -} +// TODO diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx index 5d14836..34f5619 100644 --- a/src/components/organisms/modals/settings-modal.tsx +++ b/src/components/organisms/modals/settings-modal.tsx @@ -37,12 +37,7 @@ const SettingsModal: FC<SettingsModalProps> = ({ }); return ( - <Modal - title={title} - icon="cogs" - className={`${styles.wrapper} ${className}`} - headingClassName={styles.heading} - > + <Modal title={title} icon="cogs" className={className}> <DynamicSettingsForm {...props} /> </Modal> ); diff --git a/src/components/organisms/toolbar/main-nav.stories.tsx b/src/components/organisms/toolbar/main-nav.stories.tsx index 831636f..7d6d915 100644 --- a/src/components/organisms/toolbar/main-nav.stories.tsx +++ b/src/components/organisms/toolbar/main-nav.stories.tsx @@ -63,7 +63,15 @@ const Template: ComponentStory<typeof MainNav> = ({ }) => { const [isOpen, setIsOpen] = useState<boolean>(isActive); - return <MainNav isActive={isOpen} setIsActive={setIsOpen} {...args} />; + return ( + <MainNav + isActive={isOpen} + setIsActive={() => { + setIsOpen(!isOpen); + }} + {...args} + /> + ); }; /** diff --git a/src/components/organisms/toolbar/main-nav.tsx b/src/components/organisms/toolbar/main-nav.tsx index d205112..5dd32f7 100644 --- a/src/components/organisms/toolbar/main-nav.tsx +++ b/src/components/organisms/toolbar/main-nav.tsx @@ -1,4 +1,6 @@ -import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox'; +import BooleanField, { + type BooleanFieldProps, +} from '@components/atoms/forms/boolean-field'; import Label from '@components/atoms/forms/label'; import Hamburger from '@components/atoms/icons/hamburger'; import Nav, { @@ -18,7 +20,7 @@ export type MainNavProps = { /** * The button state. */ - isActive: CheckboxProps['value']; + isActive: BooleanFieldProps['checked']; /** * The main nav items. */ @@ -26,7 +28,7 @@ export type MainNavProps = { /** * A callback function to handle button state. */ - setIsActive: CheckboxProps['setValue']; + setIsActive: BooleanFieldProps['onChange']; }; /** @@ -53,12 +55,14 @@ const MainNav: ForwardRefRenderFunction<HTMLDivElement, MainNavProps> = ( return ( <div className={`${sharedStyles.item} ${mainNavStyles.item}`} ref={ref}> - <Checkbox + <BooleanField + checked={isActive} + className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`} id="main-nav-button" name="main-nav-button" - value={isActive} - setValue={setIsActive} - className={`${sharedStyles.checkbox} ${mainNavStyles.checkbox}`} + onChange={setIsActive} + type="checkbox" + value="open" /> <Label htmlFor="main-nav-button" diff --git a/src/components/organisms/toolbar/search.stories.tsx b/src/components/organisms/toolbar/search.stories.tsx index f0f65b4..4baf0bf 100644 --- a/src/components/organisms/toolbar/search.stories.tsx +++ b/src/components/organisms/toolbar/search.stories.tsx @@ -68,7 +68,15 @@ const Template: ComponentStory<typeof Search> = ({ }) => { const [isOpen, setIsOpen] = useState<boolean>(isActive); - return <Search isActive={isOpen} setIsActive={setIsOpen} {...args} />; + return ( + <Search + isActive={isOpen} + setIsActive={() => { + setIsOpen(!isOpen); + }} + {...args} + /> + ); }; /** diff --git a/src/components/organisms/toolbar/search.tsx b/src/components/organisms/toolbar/search.tsx index 6a8af26..2d4b6b5 100644 --- a/src/components/organisms/toolbar/search.tsx +++ b/src/components/organisms/toolbar/search.tsx @@ -1,4 +1,6 @@ -import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox'; +import BooleanField, { + type BooleanFieldProps, +} from '@components/atoms/forms/boolean-field'; import MagnifyingGlass from '@components/atoms/icons/magnifying-glass'; import FlippingLabel from '@components/molecules/forms/flipping-label'; import useInputAutofocus from '@utils/hooks/use-input-autofocus'; @@ -16,7 +18,7 @@ export type SearchProps = { /** * The button state. */ - isActive: CheckboxProps['value']; + isActive: BooleanFieldProps['checked']; /** * A callback function to execute search. */ @@ -24,7 +26,7 @@ export type SearchProps = { /** * A callback function to handle button state. */ - setIsActive: CheckboxProps['setValue']; + setIsActive: BooleanFieldProps['onChange']; }; const Search: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = ( @@ -53,12 +55,14 @@ const Search: ForwardRefRenderFunction<HTMLDivElement, SearchProps> = ( return ( <div className={`${sharedStyles.item} ${searchStyles.item}`} ref={ref}> - <Checkbox + <BooleanField + checked={isActive} + className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`} id="search-button" name="search-button" - value={isActive} - setValue={setIsActive} - className={`${sharedStyles.checkbox} ${searchStyles.checkbox}`} + onChange={setIsActive} + type="checkbox" + value="open" /> <FlippingLabel className={sharedStyles.label} diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx index 08ec579..20d0b4d 100644 --- a/src/components/organisms/toolbar/settings.stories.tsx +++ b/src/components/organisms/toolbar/settings.stories.tsx @@ -92,7 +92,15 @@ const Template: ComponentStory<typeof Settings> = ({ }) => { const [isOpen, setIsOpen] = useState<boolean>(isActive); - return <Settings isActive={isOpen} setIsActive={setIsOpen} {...args} />; + return ( + <Settings + isActive={isOpen} + setIsActive={() => { + setIsOpen(!isOpen); + }} + {...args} + /> + ); }; /** diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx index ceb6db4..0e7daa2 100644 --- a/src/components/organisms/toolbar/settings.tsx +++ b/src/components/organisms/toolbar/settings.tsx @@ -1,4 +1,6 @@ -import Checkbox, { type CheckboxProps } from '@components/atoms/forms/checkbox'; +import BooleanField, { + type BooleanFieldProps, +} from '@components/atoms/forms/boolean-field'; import Cog from '@components/atoms/icons/cog'; import FlippingLabel from '@components/molecules/forms/flipping-label'; import { forwardRef, ForwardRefRenderFunction } from 'react'; @@ -13,11 +15,11 @@ export type SettingsProps = SettingsModalProps & { /** * The button state. */ - isActive: CheckboxProps['value']; + isActive: BooleanFieldProps['checked']; /** * A callback function to handle button state. */ - setIsActive: CheckboxProps['setValue']; + setIsActive: BooleanFieldProps['onChange']; }; const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = ( @@ -46,12 +48,14 @@ const Settings: ForwardRefRenderFunction<HTMLDivElement, SettingsProps> = ( return ( <div className={`${sharedStyles.item} ${settingsStyles.item}`} ref={ref}> - <Checkbox + <BooleanField + checked={isActive} + className={`${sharedStyles.checkbox} ${settingsStyles.checkbox}`} id="settings-button" name="settings-button" - value={isActive} - setValue={setIsActive} - className={`${sharedStyles.checkbox} ${settingsStyles.checkbox}`} + onChange={setIsActive} + type="checkbox" + value="open" /> <FlippingLabel className={sharedStyles.label} diff --git a/src/components/organisms/toolbar/toolbar.module.scss b/src/components/organisms/toolbar/toolbar.module.scss index 4bcabcb..ca9cd33 100644 --- a/src/components/organisms/toolbar/toolbar.module.scss +++ b/src/components/organisms/toolbar/toolbar.module.scss @@ -40,7 +40,7 @@ &--settings { @include mix.media("screen") { @include mix.dimensions("sm") { - min-width: 35ch; + min-width: 32ch; } } } diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx index ee61a7b..c18b8ea 100644 --- a/src/components/organisms/toolbar/toolbar.tsx +++ b/src/components/organisms/toolbar/toolbar.tsx @@ -50,14 +50,14 @@ const Toolbar: FC<ToolbarProps> = ({ <MainNav items={nav} isActive={isNavOpened} - setIsActive={setIsNavOpened} + setIsActive={() => setIsNavOpened(!isNavOpened)} className={styles.modal} ref={mainNavRef} /> <Search searchPage={searchPage} isActive={isSearchOpened} - setIsActive={setIsSearchOpened} + setIsActive={() => setIsSearchOpened(!isSearchOpened)} className={`${styles.modal} ${styles['modal--search']}`} ref={searchRef} /> @@ -67,7 +67,7 @@ const Toolbar: FC<ToolbarProps> = ({ isActive={isSettingsOpened} motionStorageKey={motionStorageKey} ref={settingsRef} - setIsActive={setIsSettingsOpened} + setIsActive={() => setIsSettingsOpened(!isSettingsOpened)} tooltipClassName={styles.tooltip} /> </div> |
