diff options
Diffstat (limited to 'src/components/molecules/forms')
40 files changed, 1865 insertions, 986 deletions
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; |
