From 8320b1d39ea6402c32e907dbb35082efc6af9f5a Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 31 May 2022 19:40:23 +0200 Subject: chore: replace the toggle component --- src/components/atoms/forms/fieldset.test.tsx | 4 +- .../molecules/forms/ackee-select.fixture.tsx | 1 + .../molecules/forms/ackee-select.stories.tsx | 2 + .../molecules/forms/ackee-select.test.tsx | 5 +- .../molecules/forms/motion-toggle.fixture.tsx | 1 + .../molecules/forms/motion-toggle.stories.tsx | 30 ++--- .../molecules/forms/motion-toggle.test.tsx | 12 +- src/components/molecules/forms/motion-toggle.tsx | 82 ++++++++++--- .../molecules/forms/prism-theme-toggle.stories.tsx | 17 +-- .../molecules/forms/prism-theme-toggle.test.tsx | 4 +- .../molecules/forms/prism-theme-toggle.tsx | 81 +++++++++---- .../molecules/forms/radio-group.module.scss | 111 ++++++++++++++++- .../molecules/forms/radio-group.stories.tsx | 54 +++++++++ src/components/molecules/forms/radio-group.tsx | 81 +++++++++++-- .../molecules/forms/theme-toggle.stories.tsx | 17 +-- .../molecules/forms/theme-toggle.test.tsx | 4 +- src/components/molecules/forms/theme-toggle.tsx | 73 +++++++---- src/components/molecules/forms/toggle.module.scss | 75 ------------ src/components/molecules/forms/toggle.stories.tsx | 134 --------------------- src/components/molecules/forms/toggle.test.tsx | 29 ----- src/components/molecules/forms/toggle.tsx | 90 -------------- .../organisms/forms/settings-form.module.scss | 69 +++++++++-- .../organisms/forms/settings-form.stories.tsx | 6 + .../organisms/forms/settings-form.test.tsx | 6 +- src/components/organisms/forms/settings-form.tsx | 21 ++-- .../organisms/toolbar/toolbar.module.scss | 2 +- src/utils/hooks/use-state-change.tsx | 19 +++ 27 files changed, 534 insertions(+), 496 deletions(-) create mode 100644 src/components/molecules/forms/ackee-select.fixture.tsx create mode 100644 src/components/molecules/forms/motion-toggle.fixture.tsx delete mode 100644 src/components/molecules/forms/toggle.module.scss delete mode 100644 src/components/molecules/forms/toggle.stories.tsx delete mode 100644 src/components/molecules/forms/toggle.test.tsx delete mode 100644 src/components/molecules/forms/toggle.tsx create mode 100644 src/utils/hooks/use-state-change.tsx (limited to 'src') 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(
{body}
); - 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 = (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(); + render(); 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(); + render(); 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; @@ -66,5 +55,6 @@ const Template: ComponentStory = (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(); + // toHaveValue received undefined. Maybe because of localStorage hook... + it('renders a toggle component', () => { + render(); 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,16 +1,24 @@ -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. */ @@ -23,14 +31,14 @@ export type MotionToggleProps = Pick< * Render a Toggle component to set reduce motion. */ const MotionToggle: FC = ({ + defaultValue, storageKey, - value, ...props }) => { const intl = useIntl(); const { value: isReduced, setValue: setIsReduced } = useLocalStorage( storageKey, - value + defaultValue === 'on' ? false : true ); useAttributes({ element: document.documentElement || undefined, @@ -53,20 +61,56 @@ const MotionToggle: FC = ({ 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 ( - ); 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(); 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 = ({ ...props }) => { +const PrismThemeToggle: FC = (props) => { const intl = useIntl(); const { theme, setTheme, resolvedTheme } = usePrismTheme(); @@ -27,16 +29,36 @@ const PrismThemeToggle: FC = ({ ...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 = ({ ...props }) => { description: 'PrismThemeToggle: dark theme label', id: 'og/zWL', }); - const themeChoices: ToggleChoices = { - left: , - right: , - }; + + const options: RadioGroupOption[] = [ + { + id: 'code-blocks-light', + label: , + name: 'code-blocks', + value: 'light', + }, + { + id: 'code-blocks-dark', + label: , + name: 'code-blocks', + value: 'dark', + }, + ]; return ( - ); 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) => void; +}; + +export type RadioGroupCallback = (props: RadioGroupCallbackProps) => void; + export type RadioGroupOption = Pick< LabelledBooleanFieldProps, 'id' | 'label' | 'name' | 'value' @@ -15,14 +26,34 @@ export type RadioGroupProps = Pick< 'className' | 'legend' | 'legendClassName' > & Pick & { + /** + * 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. */ @@ -36,23 +67,38 @@ export type RadioGroupProps = Pick< */ const RadioGroup: FC = ({ className, + groupClassName = '', initialChoice, + kind = 'regular', labelPosition, labelSize, legendPosition = 'inline', + onChange, + optionClassName = '', options, ...props }) => { - const [selectedChoice, setSelectedChoice] = useState(initialChoice); - const wrapperModifier = `wrapper--${legendPosition}`; + const [selectedChoice, setSelectedChoice] = + useStateChange(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} e - The change event. + * Update the selected choice on click or change event. */ - const updateChoice = (e: ChangeEvent) => { - setSelectedChoice(e.target.value); + const updateChoice = ( + e: + | ChangeEvent + | 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 = ({