diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-31 19:40:23 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-06-01 22:32:09 +0200 |
| commit | 8320b1d39ea6402c32e907dbb35082efc6af9f5a (patch) | |
| tree | b5ee9586a4ec91aa15c92dcb513b551716fd4416 /src/components | |
| parent | 994ad1bec193b2d1a6e0d38d6ef3f3d2bd66c3ea (diff) | |
chore: replace the toggle component
Diffstat (limited to 'src/components')
26 files changed, 515 insertions, 496 deletions
diff --git a/src/components/atoms/forms/fieldset.test.tsx b/src/components/atoms/forms/fieldset.test.tsx index 0f84f83..1d1d246 100644 --- a/src/components/atoms/forms/fieldset.test.tsx +++ b/src/components/atoms/forms/fieldset.test.tsx @@ -5,7 +5,7 @@ import { body, legend } from './fieldset.fixture'; describe('Fieldset', () => { it('renders a legend and a body', () => { render(<Fieldset legend={legend}>{body}</Fieldset>); - expect(screen.findByRole('group', { name: legend })).toBeInTheDocument(); - expect(screen.findByText(body)).toBeInTheDocument(); + expect(screen.findByRole('group', { name: legend })).toBeTruthy(); + expect(screen.findByText(body)).toBeTruthy(); }); }); diff --git a/src/components/molecules/forms/ackee-select.fixture.tsx b/src/components/molecules/forms/ackee-select.fixture.tsx new file mode 100644 index 0000000..04602f2 --- /dev/null +++ b/src/components/molecules/forms/ackee-select.fixture.tsx @@ -0,0 +1 @@ +export const storageKey = 'ackee'; diff --git a/src/components/molecules/forms/ackee-select.stories.tsx b/src/components/molecules/forms/ackee-select.stories.tsx index 81eb5df..f8d04f6 100644 --- a/src/components/molecules/forms/ackee-select.stories.tsx +++ b/src/components/molecules/forms/ackee-select.stories.tsx @@ -1,5 +1,6 @@ import { ComponentMeta, ComponentStory } from '@storybook/react'; import AckeeSelect from './ackee-select'; +import { storageKey } from './ackee-select.fixture'; /** * AckeeSelect - Storybook Meta @@ -81,4 +82,5 @@ const Template: ComponentStory<typeof AckeeSelect> = (args) => ( export const Ackee = Template.bind({}); Ackee.args = { initialValue: 'full', + storageKey, }; diff --git a/src/components/molecules/forms/ackee-select.test.tsx b/src/components/molecules/forms/ackee-select.test.tsx index 0089c06..d255b00 100644 --- a/src/components/molecules/forms/ackee-select.test.tsx +++ b/src/components/molecules/forms/ackee-select.test.tsx @@ -1,16 +1,17 @@ import user from '@testing-library/user-event'; import { act, render, screen } from '@test-utils'; import AckeeSelect from './ackee-select'; +import { storageKey } from './ackee-select.fixture'; describe('Select', () => { it('should correctly set default option', () => { - render(<AckeeSelect storageKey="ackee-tracking" initialValue="full" />); + render(<AckeeSelect storageKey={storageKey} 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" />); + render(<AckeeSelect storageKey={storageKey} initialValue="full" />); await act(async () => { await user.selectOptions( 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..5c524a8 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,24 +9,22 @@ export default { title: 'Molecules/Forms/Toggle', component: MotionToggleComponent, argTypes: { - className: { + defaultValue: { control: { - type: 'text', - }, - description: 'Set additional classnames to the toggle wrapper.', - table: { - category: 'Styles', + type: 'select', }, + description: 'Set the default value.', + options: ['on', 'off'], type: { name: 'string', - required: false, + required: true, }, }, - labelClassName: { + legendClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the label wrapper.', + description: 'Set additional classnames to the legend.', table: { category: 'Styles', }, @@ -44,16 +43,6 @@ export default { required: true, }, }, - value: { - control: { - type: null, - }, - description: 'The reduce motion value.', - type: { - name: 'boolean', - required: true, - }, - }, }, } as ComponentMeta<typeof MotionToggleComponent>; @@ -66,5 +55,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..6925248 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, + '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..3f57fa5 100644 --- a/src/components/molecules/forms/prism-theme-toggle.stories.tsx +++ b/src/components/molecules/forms/prism-theme-toggle.stories.tsx @@ -8,24 +8,11 @@ export default { title: 'Molecules/Forms/Toggle', component: PrismThemeToggle, argTypes: { - className: { + legendClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the toggle wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - labelClassName: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the label wrapper.', + 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..66be056 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, + '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.module.scss b/src/components/molecules/forms/radio-group.module.scss index feda9bd..0bd34b9 100644 --- a/src/components/molecules/forms/radio-group.module.scss +++ b/src/components/molecules/forms/radio-group.module.scss @@ -1,13 +1,112 @@ -.option { - &:not(:last-of-type) { - margin-right: var(--spacing-xs); - } -} +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/mixins" as mix; .wrapper { - &--inline { + &--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 index b4c913a..3c01af5 100644 --- a/src/components/molecules/forms/radio-group.stories.tsx +++ b/src/components/molecules/forms/radio-group.stories.tsx @@ -9,6 +9,7 @@ export default { title: 'Molecules/Forms/RadioGroup', component: RadioGroup, args: { + kind: 'regular', labelSize: 'small', }, argTypes: { @@ -35,6 +36,21 @@ export default { 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', @@ -102,6 +118,32 @@ export default { required: false, }, }, + onChange: { + control: { + type: null, + }, + description: 'A callback function to handle selected option change.', + 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: { @@ -164,3 +206,15 @@ StackedLegendRightLabel.args = { 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.tsx b/src/components/molecules/forms/radio-group.tsx index 68a8adf..45f585e 100644 --- a/src/components/molecules/forms/radio-group.tsx +++ b/src/components/molecules/forms/radio-group.tsx @@ -1,10 +1,21 @@ import Fieldset, { type FieldsetProps } from '@components/atoms/forms/fieldset'; -import { ChangeEvent, FC, useState } from 'react'; +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' @@ -16,14 +27,34 @@ export type RadioGroupProps = Pick< > & 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[]; @@ -36,23 +67,38 @@ export type RadioGroupProps = Pick< */ const RadioGroup: FC<RadioGroupProps> = ({ className, + groupClassName = '', initialChoice, + kind = 'regular', labelPosition, labelSize, legendPosition = 'inline', + onChange, + optionClassName = '', options, ...props }) => { - const [selectedChoice, setSelectedChoice] = useState<string>(initialChoice); - const wrapperModifier = `wrapper--${legendPosition}`; + 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 based on the change event target. - * - * @param {ChangeEvent<HTMLInputElement>} e - The change event. + * Update the selected choice on click or change event. */ - const updateChoice = (e: ChangeEvent<HTMLInputElement>) => { - setSelectedChoice(e.target.value); + 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); }; /** @@ -65,10 +111,14 @@ const RadioGroup: FC<RadioGroupProps> = ({ <LabelledBooleanField key={option.id} checked={selectedChoice === option.value} - className={styles.option} - labelPosition={labelPosition} + 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} /> @@ -77,11 +127,18 @@ const RadioGroup: FC<RadioGroupProps> = ({ return ( <Fieldset - className={`${styles.wrapper} ${styles[wrapperModifier]} ${className}`} + className={`${styles.wrapper} ${styles[alignmentModifier]} ${styles[toggleModifier]} ${className}`} legendPosition={legendPosition} + role="radiogroup" {...props} > - {getOptions()} + {isToggle ? ( + <span className={`${styles.toggle} ${groupClassName}`}> + {getOptions()} + </span> + ) : ( + getOptions() + )} </Fieldset> ); }; diff --git a/src/components/molecules/forms/theme-toggle.stories.tsx b/src/components/molecules/forms/theme-toggle.stories.tsx index a7bebb4..cd59d7e 100644 --- a/src/components/molecules/forms/theme-toggle.stories.tsx +++ b/src/components/molecules/forms/theme-toggle.stories.tsx @@ -8,24 +8,11 @@ export default { title: 'Molecules/Forms/Toggle', component: ThemeToggle, argTypes: { - className: { + legendClassName: { control: { type: 'text', }, - description: 'Set additional classnames to the toggle wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - labelClassName: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the label wrapper.', + 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..30bc55c 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, + '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 2f3e778..0000000 --- a/src/components/molecules/forms/toggle.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import BooleanField, { - type BooleanFieldProps, -} from '@components/atoms/forms/boolean-field'; -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<BooleanFieldProps, '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 ( - <> - <BooleanField - checked={value} - className={styles.checkbox} - id={id} - name={name} - onChange={() => setValue(!value)} - type="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/organisms/forms/settings-form.module.scss b/src/components/organisms/forms/settings-form.module.scss index a05c60c..6174304 100644 --- a/src/components/organisms/forms/settings-form.module.scss +++ b/src/components/organisms/forms/settings-form.module.scss @@ -1,26 +1,77 @@ @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") { + gap: var(--spacing-md); 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") { + font-size: var(--font-size-sm); + + &:not(.label--select) { + float: none; + margin: 0 auto; + } + } + } + + &.label--select { + @include mix.media("screen") { + @include mix.dimensions("2xs", null, "height") { + margin: 0 auto 0 0; + } + } + } + } + + .tooltip { + @include mix.media("screen") { + @include mix.dimensions(null, "2xs") { + font-size: var(--font-size-sm); + } } } } .items { + margin: var(--spacing-2xs) 0; + + @include mix.media("screen") { + @include mix.dimensions(null, "2xs", "height") { + display: flex; + flex-flow: column wrap; + width: fit-content; + + &:last-of-type { + flex: 0 0 100%; + margin: 0; + } + } + } +} + +.setting { + &--select { + flex: 0 0 100%; + } +} + +.group { + margin-left: auto; + @include mix.media("screen") { @include mix.dimensions(null, "2xs", "height") { - margin: var(--spacing-2xs) 0; + margin: auto; } } } diff --git a/src/components/organisms/forms/settings-form.stories.tsx b/src/components/organisms/forms/settings-form.stories.tsx index 70e1844..ceb08c7 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-select.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..90a2751 100644 --- a/src/components/organisms/forms/settings-form.test.tsx +++ b/src/components/organisms/forms/settings-form.test.tsx @@ -25,7 +25,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Theme:/i }) + screen.getByRole('radiogroup', { name: /^Theme:/i }) ).toBeInTheDocument(); }); @@ -37,7 +37,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Code blocks:/i }) + screen.getByRole('radiogroup', { name: /^Code blocks:/i }) ).toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe('SettingsForm', () => { /> ); expect( - screen.getByRole('checkbox', { name: /^Animations:/i }) + screen.getByRole('radiogroup', { name: /^Animations:/i }) ).toBeInTheDocument(); }); diff --git a/src/components/organisms/forms/settings-form.tsx b/src/components/organisms/forms/settings-form.tsx index 9c2cd2c..9dc0e90 100644 --- a/src/components/organisms/forms/settings-form.tsx +++ b/src/components/organisms/forms/settings-form.tsx @@ -3,7 +3,7 @@ import AckeeSelect, { type AckeeSelectProps, } from '@components/molecules/forms/ackee-select'; 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'; @@ -37,25 +37,28 @@ 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 + groupClassName={styles.group} + legendClassName={styles.label} + /> <PrismThemeToggle - className={styles.setting} - labelClassName={styles.label} + groupClassName={styles.group} + legendClassName={styles.label} /> <MotionToggle - className={styles.setting} - labelClassName={styles.label} + defaultValue="on" + groupClassName={styles.group} + legendClassName={styles.label} storageKey={motionStorageKey} - value={false} /> <AckeeSelect - className={styles.setting} initialValue="full" labelClassName={`${styles.label} ${styles['label--select']}`} - tooltipClassName={tooltipClassName} + tooltipClassName={`${styles.tooltip} ${tooltipClassName}`} storageKey={ackeeStorageKey} /> </Form> 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; } } } |
