diff options
29 files changed, 335 insertions, 209 deletions
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index de6afd3..f3f374f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,7 +3,7 @@ import { ThemeProvider, useTheme } from 'next-themes'; import { useDarkMode } from 'storybook-dark-mode'; import { FC, ReactNode, useEffect } from 'react'; import { IntlProvider } from 'react-intl'; -import { AckeeProvider } from '../src/utils/providers'; +import { AckeeProvider, MotionProvider } from '../src/utils/providers'; import '../src/styles/globals.scss'; import { DocsContainer } from './overrides/docs-container'; import dark from './themes/dark'; @@ -33,16 +33,18 @@ const withAllProviders: Decorator = (Story) => { enableColorScheme={true} enableSystem={true} > - <AckeeProvider - domainId="any" - server="https://example.com" - storageKey="ackee" - tracking="full" - > - <ThemeWrapper> - <Story /> - </ThemeWrapper> - </AckeeProvider> + <MotionProvider attribute="reduced-motion" storageKey="reduced-motion"> + <AckeeProvider + domainId="any" + server="https://example.com" + storageKey="ackee" + tracking="full" + > + <ThemeWrapper> + <Story /> + </ThemeWrapper> + </AckeeProvider> + </MotionProvider> </ThemeProvider> </IntlProvider> ); diff --git a/src/components/molecules/forms/switch/switch.tsx b/src/components/molecules/forms/switch/switch.tsx index df2ba0c..ad3e514 100644 --- a/src/components/molecules/forms/switch/switch.tsx +++ b/src/components/molecules/forms/switch/switch.tsx @@ -55,9 +55,7 @@ const SwitchItem: FC<SwitchItemProps> = ({ value, ...props }) => { - const selectedItemClass = isSelected ? styles['item--selected'] : ''; - const disabledItemClass = isDisabled ? styles['item--disabled'] : ''; - const itemClass = `${styles.item} ${selectedItemClass} ${disabledItemClass} ${className}`; + const itemClass = `${styles.item} ${className}`; return ( <Label {...props} className={itemClass} htmlFor={id}> diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.ts b/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.ts deleted file mode 100644 index f13658a..0000000 --- a/src/components/organisms/forms/motion-toggle/motion-toggle.fixture.ts +++ /dev/null @@ -1 +0,0 @@ -export const storageKey = 'reduced-motion'; diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx index 811830b..7adef1b 100644 --- a/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx +++ b/src/components/organisms/forms/motion-toggle/motion-toggle.stories.tsx @@ -1,6 +1,5 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { MotionToggle } from './motion-toggle'; -import { storageKey } from './motion-toggle.fixture'; /** * MotionToggle - Storybook Meta @@ -8,29 +7,7 @@ import { storageKey } from './motion-toggle.fixture'; export default { title: 'Organisms/Forms/Toggle', component: MotionToggle, - argTypes: { - defaultValue: { - control: { - type: 'select', - }, - description: 'Set the default value.', - options: ['on', 'off'], - type: { - name: 'string', - required: true, - }, - }, - storageKey: { - control: { - type: 'text', - }, - description: 'Set local storage key.', - type: { - name: 'string', - required: true, - }, - }, - }, + argTypes: {}, } as ComponentMeta<typeof MotionToggle>; const Template: ComponentStory<typeof MotionToggle> = (args) => ( @@ -41,7 +18,4 @@ const Template: ComponentStory<typeof MotionToggle> = (args) => ( * Toggle Stories - Motion */ export const Motion = Template.bind({}); -Motion.args = { - defaultValue: 'on', - storageKey, -}; +Motion.args = {}; diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx index 6952f46..d20057e 100644 --- a/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx +++ b/src/components/organisms/forms/motion-toggle/motion-toggle.test.tsx @@ -1,12 +1,11 @@ import { describe, expect, it } from '@jest/globals'; import { render, screen as rtlScreen } from '../../../../../tests/utils'; import { MotionToggle } from './motion-toggle'; -import { storageKey } from './motion-toggle.fixture'; describe('MotionToggle', () => { // toHaveValue received undefined. Maybe because of localStorage hook... it('renders a toggle component', () => { - render(<MotionToggle storageKey={storageKey} defaultValue="on" />); + render(<MotionToggle />); expect( rtlScreen.getByRole('radiogroup', { name: /Animations:/i, diff --git a/src/components/organisms/forms/motion-toggle/motion-toggle.tsx b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx index 2545c20..33527c3 100644 --- a/src/components/organisms/forms/motion-toggle/motion-toggle.tsx +++ b/src/components/organisms/forms/motion-toggle/motion-toggle.tsx @@ -1,6 +1,6 @@ -import { useCallback, type FC } from 'react'; +import type { FC } from 'react'; import { useIntl } from 'react-intl'; -import { useAttributes, useLocalStorage } from '../../../../utils/hooks'; +import { useReducedMotion } from '../../../../utils/hooks'; import { Legend } from '../../../atoms'; import { Switch, @@ -8,47 +8,19 @@ import { type SwitchProps, } from '../../../molecules'; -export type MotionToggleValue = 'on' | 'off'; - -const validator = (value: unknown): value is boolean => - typeof value === 'boolean'; - export type MotionToggleProps = Omit< SwitchProps, 'isInline' | 'items' | 'name' | 'onSwitch' | 'value' -> & { - /** - * True if motion should be reduced by default. - */ - defaultValue: MotionToggleValue; - /** - * The local storage key to save preference. - */ - storageKey: string; -}; +>; /** * MotionToggle component * * Render a Toggle component to set reduce motion. */ -export const MotionToggle: FC<MotionToggleProps> = ({ - defaultValue, - storageKey, - ...props -}) => { +export const MotionToggle: FC<MotionToggleProps> = ({ ...props }) => { const intl = useIntl(); - const [isReduced, setIsReduced] = useLocalStorage( - storageKey, - defaultValue !== 'on', - validator - ); - useAttributes({ - element: - typeof window === 'undefined' ? undefined : document.documentElement, - attribute: 'reduced-motion', - value: `${isReduced}`, - }); + const { isReduced, toggleReducedMotion } = useReducedMotion(); const reduceMotionLabel = intl.formatMessage({ defaultMessage: 'Animations:', @@ -79,10 +51,6 @@ export const MotionToggle: FC<MotionToggleProps> = ({ }, ]; - const updateSetting = useCallback(() => { - setIsReduced((prev) => !prev); - }, [setIsReduced]); - return ( <Switch {...props} @@ -90,7 +58,7 @@ export const MotionToggle: FC<MotionToggleProps> = ({ items={options} legend={<Legend>{reduceMotionLabel}</Legend>} name="reduced-motion" - onSwitch={updateSetting} + onSwitch={toggleReducedMotion} value={isReduced ? 'off' : 'on'} /> ); diff --git a/src/components/organisms/modals/settings-modal.stories.tsx b/src/components/organisms/modals/settings-modal.stories.tsx index 57ce00f..7c56f27 100644 --- a/src/components/organisms/modals/settings-modal.stories.tsx +++ b/src/components/organisms/modals/settings-modal.stories.tsx @@ -1,5 +1,4 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { storageKey as motionStorageKey } from '../forms/motion-toggle/motion-toggle.fixture'; import { SettingsModal } from './settings-modal'; /** @@ -22,16 +21,6 @@ export default { required: false, }, }, - motionStorageKey: { - control: { - type: 'text', - }, - description: 'A local storage key for reduced motion setting..', - type: { - name: 'string', - required: true, - }, - }, tooltipClassName: { control: { type: 'text', @@ -59,6 +48,4 @@ const Template: ComponentStory<typeof SettingsModal> = (args) => ( * Modals Stories - Settings */ export const Settings = Template.bind({}); -Settings.args = { - motionStorageKey, -}; +Settings.args = {}; diff --git a/src/components/organisms/modals/settings-modal.test.tsx b/src/components/organisms/modals/settings-modal.test.tsx index 26d046a..af2b6e9 100644 --- a/src/components/organisms/modals/settings-modal.test.tsx +++ b/src/components/organisms/modals/settings-modal.test.tsx @@ -1,16 +1,15 @@ import { describe, expect, it } from '@jest/globals'; import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { storageKey as motionStorageKey } from '../forms/motion-toggle/motion-toggle.fixture'; import { SettingsModal } from './settings-modal'; describe('SettingsModal', () => { it('renders the modal heading', () => { - render(<SettingsModal motionStorageKey={motionStorageKey} />); + render(<SettingsModal />); expect(rtlScreen.getByText(/Settings/i)).toBeInTheDocument(); }); it('renders a settings form', () => { - render(<SettingsModal motionStorageKey={motionStorageKey} />); + render(<SettingsModal />); expect( rtlScreen.getByRole('form', { name: /^Settings form/i }) ).toBeInTheDocument(); diff --git a/src/components/organisms/modals/settings-modal.tsx b/src/components/organisms/modals/settings-modal.tsx index f62312b..5fea491 100644 --- a/src/components/organisms/modals/settings-modal.tsx +++ b/src/components/organisms/modals/settings-modal.tsx @@ -4,28 +4,19 @@ import { Form, Heading, Icon, Modal, type ModalProps } from '../../atoms'; import { AckeeToggle, MotionToggle, - type MotionToggleProps, PrismThemeToggle, ThemeToggle, } from '../forms'; import styles from './settings-modal.module.scss'; -export type SettingsModalProps = Pick<ModalProps, 'className'> & { - /** - * The local storage key for Reduce motion settings. - */ - motionStorageKey: MotionToggleProps['storageKey']; -}; +export type SettingsModalProps = Pick<ModalProps, 'className'>; /** * SettingsModal component * * Render a modal with settings options. */ -export const SettingsModal: FC<SettingsModalProps> = ({ - className = '', - motionStorageKey, -}) => { +export const SettingsModal: FC<SettingsModalProps> = ({ className = '' }) => { const intl = useIntl(); const title = intl.formatMessage({ defaultMessage: 'Settings', @@ -59,11 +50,7 @@ export const SettingsModal: FC<SettingsModalProps> = ({ > <ThemeToggle className={styles.item} /> <PrismThemeToggle className={styles.item} /> - <MotionToggle - className={styles.item} - defaultValue="on" - storageKey={motionStorageKey} - /> + <MotionToggle className={styles.item} /> <AckeeToggle className={styles.item} direction="upwards" /> </Form> </Modal> diff --git a/src/components/organisms/toolbar/settings.stories.tsx b/src/components/organisms/toolbar/settings.stories.tsx index 66b4e0f..793c521 100644 --- a/src/components/organisms/toolbar/settings.stories.tsx +++ b/src/components/organisms/toolbar/settings.stories.tsx @@ -8,9 +8,6 @@ import { Settings } from './settings'; export default { title: 'Organisms/Toolbar/Settings', component: Settings, - args: { - motionStorageKey: 'reduced-motion', - }, argTypes: { className: { control: { @@ -38,16 +35,6 @@ export default { required: true, }, }, - motionStorageKey: { - control: { - type: 'text', - }, - description: 'Set Reduced motion settings local storage key.', - type: { - name: 'string', - required: true, - }, - }, setIsActive: { control: { type: null, diff --git a/src/components/organisms/toolbar/settings.test.tsx b/src/components/organisms/toolbar/settings.test.tsx index 66fa6a6..6dbed2b 100644 --- a/src/components/organisms/toolbar/settings.test.tsx +++ b/src/components/organisms/toolbar/settings.test.tsx @@ -8,26 +8,14 @@ const doNothing = () => { describe('Settings', () => { it('renders a button to open settings modal', () => { - render( - <Settings - motionStorageKey="reduced-motion" - isActive={false} - setIsActive={doNothing} - /> - ); + render(<Settings isActive={false} setIsActive={doNothing} />); expect( rtlScreen.getByRole('checkbox', { name: 'Open settings' }) ).toBeInTheDocument(); }); it('renders a button to close settings modal', () => { - render( - <Settings - motionStorageKey="reduced-motion" - isActive={true} - setIsActive={doNothing} - /> - ); + render(<Settings isActive={true} setIsActive={doNothing} />); expect( rtlScreen.getByRole('checkbox', { name: 'Close settings' }) ).toBeInTheDocument(); diff --git a/src/components/organisms/toolbar/settings.tsx b/src/components/organisms/toolbar/settings.tsx index 124dd42..1b68db8 100644 --- a/src/components/organisms/toolbar/settings.tsx +++ b/src/components/organisms/toolbar/settings.tsx @@ -19,10 +19,7 @@ export type SettingsProps = SettingsModalProps & { const SettingsWithRef: ForwardRefRenderFunction< HTMLDivElement, SettingsProps -> = ( - { className = '', isActive = false, motionStorageKey, setIsActive }, - ref -) => { +> = ({ className = '', isActive = false, setIsActive }, ref) => { const intl = useIntl(); const label = isActive ? intl.formatMessage({ @@ -54,10 +51,7 @@ const SettingsWithRef: ForwardRefRenderFunction< isActive={isActive} label={label} /> - <SettingsModal - className={`${styles.modal} ${className}`} - motionStorageKey={motionStorageKey} - /> + <SettingsModal className={`${styles.modal} ${className}`} /> </div> ); }; diff --git a/src/components/organisms/toolbar/toolbar.stories.tsx b/src/components/organisms/toolbar/toolbar.stories.tsx index 22bead9..19dc135 100644 --- a/src/components/organisms/toolbar/toolbar.stories.tsx +++ b/src/components/organisms/toolbar/toolbar.stories.tsx @@ -8,7 +8,6 @@ export default { title: 'Organisms/Toolbar', component: ToolbarComponent, args: { - motionStorageKey: 'reduced-motion', searchPage: '#', }, argTypes: { @@ -25,16 +24,6 @@ export default { required: false, }, }, - motionStorageKey: { - control: { - type: 'text', - }, - description: 'Set Reduced motion settings local storage key.', - type: { - name: 'string', - required: true, - }, - }, nav: { description: 'The main nav items.', type: { diff --git a/src/components/organisms/toolbar/toolbar.test.tsx b/src/components/organisms/toolbar/toolbar.test.tsx index e6b1022..23b13c1 100644 --- a/src/components/organisms/toolbar/toolbar.test.tsx +++ b/src/components/organisms/toolbar/toolbar.test.tsx @@ -11,9 +11,7 @@ const nav = [ describe('Toolbar', () => { it('renders a navigation menu', () => { - render( - <Toolbar motionStorageKey="reduced-motion" nav={nav} searchPage="#" /> - ); + render(<Toolbar nav={nav} searchPage="#" />); expect(rtlScreen.getByRole('navigation')).toBeInTheDocument(); }); }); diff --git a/src/components/organisms/toolbar/toolbar.tsx b/src/components/organisms/toolbar/toolbar.tsx index be46636..c400285 100644 --- a/src/components/organisms/toolbar/toolbar.tsx +++ b/src/components/organisms/toolbar/toolbar.tsx @@ -3,20 +3,19 @@ import { type FC, useState, useCallback } from 'react'; import { useOnClickOutside, useRouteChange } from '../../../utils/hooks'; import { MainNavItem, type MainNavItemProps } from './main-nav'; import { Search, type SearchProps } from './search'; -import { Settings, type SettingsProps } from './settings'; +import { Settings } from './settings'; import styles from './toolbar.module.scss'; -export type ToolbarProps = Pick<SearchProps, 'searchPage'> & - Pick<SettingsProps, 'motionStorageKey'> & { - /** - * Set additional classnames to the toolbar wrapper. - */ - className?: string; - /** - * The main nav items. - */ - nav: MainNavItemProps['items']; - }; +export type ToolbarProps = Pick<SearchProps, 'searchPage'> & { + /** + * Set additional classnames to the toolbar wrapper. + */ + className?: string; + /** + * The main nav items. + */ + nav: MainNavItemProps['items']; +}; /** * Toolbar component @@ -25,7 +24,6 @@ export type ToolbarProps = Pick<SearchProps, 'searchPage'> & */ export const Toolbar: FC<ToolbarProps> = ({ className = '', - motionStorageKey, nav, searchPage, }) => { @@ -77,7 +75,6 @@ export const Toolbar: FC<ToolbarProps> = ({ <Settings className={`${styles.modal} ${styles['modal--settings']}`} isActive={isSettingsOpened} - motionStorageKey={motionStorageKey} ref={settingsRef} setIsActive={toggleSettings} /> diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx index fd3a928..9017d3c 100644 --- a/src/components/templates/layout/layout.tsx +++ b/src/components/templates/layout/layout.tsx @@ -313,8 +313,6 @@ export const Layout: FC<LayoutProps> = ({ /> <Toolbar className={styles.toolbar} - // eslint-disable-next-line react/jsx-no-literals -- Storage key allowed - motionStorageKey="reduced-motion" nav={mainNav} searchPage={ROUTES.SEARCH} /> diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 914b0b6..c332432 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -5,7 +5,11 @@ import '../styles/globals.scss'; import type { AppPropsWithLayout } from '../types'; import { settings } from '../utils/config'; import { STORAGE_KEY } from '../utils/constants'; -import { AckeeProvider, PrismThemeProvider } from '../utils/providers'; +import { + AckeeProvider, + MotionProvider, + PrismThemeProvider, +} from '../utils/providers'; const App = ({ Component, pageProps }: AppPropsWithLayout) => { const { locale, defaultLocale } = useRouter(); @@ -20,21 +24,26 @@ const App = ({ Component, pageProps }: AppPropsWithLayout) => { storageKey={STORAGE_KEY.ACKEE} tracking="full" > - <IntlProvider - locale={appLocale} - defaultLocale={defaultLocale} - messages={translation} + <MotionProvider + attribute={STORAGE_KEY.MOTION} + storageKey={STORAGE_KEY.MOTION} > - <ThemeProvider - defaultTheme="system" - enableColorScheme={true} - enableSystem={true} + <IntlProvider + locale={appLocale} + defaultLocale={defaultLocale} + messages={translation} > - <PrismThemeProvider> - {getLayout(<Component {...componentProps} />, {})} - </PrismThemeProvider> - </ThemeProvider> - </IntlProvider> + <ThemeProvider + defaultTheme="system" + enableColorScheme={true} + enableSystem={true} + > + <PrismThemeProvider> + {getLayout(<Component {...componentProps} />, {})} + </PrismThemeProvider> + </ThemeProvider> + </IntlProvider> + </MotionProvider> </AckeeProvider> ); }; diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx new file mode 100644 index 0000000..317d3af --- /dev/null +++ b/src/pages/_document.tsx @@ -0,0 +1,26 @@ +import { Html, Head, Main, NextScript } from 'next/document'; +import Script from 'next/script'; +import { STORAGE_KEY } from '../utils/constants'; + +// eslint-disable-next-line @typescript-eslint/no-shadow -- Required by NextJs +export default function Document() { + return ( + <Html> + <Head> + <Script + dangerouslySetInnerHTML={{ + __html: `!function(){const t=localStorage.getItem("${STORAGE_KEY.MOTION}"),e="string"==typeof t&&"true"===t;document.documentElement.setAttribute("data-${STORAGE_KEY.MOTION}",e)}();`, + }} + // eslint-disable-next-line react/jsx-no-literals + id="motion-hydration" + // eslint-disable-next-line react/jsx-no-literals + strategy="beforeInteractive" + /> + </Head> + <body> + <Main /> + <NextScript /> + </body> + </Html> + ); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 464db3f..62acca5 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -33,4 +33,5 @@ export const ROUTES = { export const STORAGE_KEY = { ACKEE: 'ackee-tracking', + MOTION: 'reduced-motion', } as const; diff --git a/src/utils/helpers/strings.ts b/src/utils/helpers/strings.ts index 1af0ca2..8b0f923 100644 --- a/src/utils/helpers/strings.ts +++ b/src/utils/helpers/strings.ts @@ -5,18 +5,17 @@ * @param {string} text - A text to slugify. * @returns {string} The slug. */ -export const slugify = (text: string): string => { - return text +export const slugify = (text: string): string => + text .toString() - .normalize('NFD') + .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .trim() .replace(/\s+/g, '-') - .replace(/[^\w\-]+/g, '-') - .replace(/\-\-+/g, '-') - .replace(/(^-)|(-$)/g, ''); -}; + .replace(/[^\w-]+/g, '-') + .replace(/--+/g, '-') + .replace(/(?:^-)|(?:-$)/g, ''); /** * Capitalize the first letter of a string. @@ -24,9 +23,8 @@ export const slugify = (text: string): string => { * @param {string} text - A text to capitalize. * @returns {string} The capitalized text. */ -export const capitalize = (text: string): string => { - return text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase()); -}; +export const capitalize = (text: string): string => + text.replace(/^\w/, (firstLetter) => firstLetter.toUpperCase()); /** * Convert a text from kebab case (foo-bar) to camel case (fooBar). @@ -34,6 +32,16 @@ export const capitalize = (text: string): string => { * @param {string} text - A text to transform. * @returns {string} The text in camel case. */ -export const fromKebabCaseToCamelCase = (text: string): string => { - return text.replace(/-./g, (x) => x[1].toUpperCase()); +export const fromKebabCaseToCamelCase = (text: string): string => + text.replace(/-./g, (x) => x[1].toUpperCase()); + +/** + * Retrieve a valid data attribute from a string. + * + * @param {string} str - A string. + * @returns {string} A data attribute (ie. `data-...`) + */ +export const getDataAttributeFrom = (str: string) => { + if (str.startsWith('data-')) return str; + return `data-${str}`; }; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index cf8c01c..606c259 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -17,6 +17,7 @@ export * from './use-prism'; export * from './use-query-selector-all'; export * from './use-reading-time'; export * from './use-redirection'; +export * from './use-reduced-motion'; export * from './use-route-change'; export * from './use-scroll-position'; export * from './use-settings'; diff --git a/src/utils/hooks/use-reduced-motion/index.ts b/src/utils/hooks/use-reduced-motion/index.ts new file mode 100644 index 0000000..23d9c62 --- /dev/null +++ b/src/utils/hooks/use-reduced-motion/index.ts @@ -0,0 +1 @@ +export * from './use-reduced-motion'; diff --git a/src/utils/hooks/use-reduced-motion/use-reduced-motion.test.tsx b/src/utils/hooks/use-reduced-motion/use-reduced-motion.test.tsx new file mode 100644 index 0000000..6423c4c --- /dev/null +++ b/src/utils/hooks/use-reduced-motion/use-reduced-motion.test.tsx @@ -0,0 +1,66 @@ +import { act, renderHook } from '@testing-library/react'; +import type { FC, ReactNode } from 'react'; +import { MotionProvider, type MotionProviderProps } from '../../providers'; +import { useReducedMotion } from './use-reduced-motion'; + +const createWrapper = ( + Wrapper: FC<MotionProviderProps>, + config: MotionProviderProps +) => + function CreatedWrapper({ children }: { children: ReactNode }) { + return <Wrapper {...config}>{children}</Wrapper>; + }; + +describe('useReducedMotion', () => { + it('should return the default value without provider and prevent update', () => { + const { result } = renderHook(() => useReducedMotion()); + + expect(result.current.isReduced).toBe(false); + + act(() => result.current.setIsReduced(true)); + + expect(result.current.isReduced).toBe(false); + + act(() => result.current.toggleReducedMotion()); + + expect(result.current.isReduced).toBe(false); + }); + + it('can update the value', () => { + const defaultValue = true; + + const { result } = renderHook(() => useReducedMotion(), { + wrapper: createWrapper(MotionProvider, { + attribute: 'aperiam', + hasReducedMotion: defaultValue, + storageKey: 'voluptate', + }), + }); + + expect(result.current.isReduced).toBe(defaultValue); + + const newValue = false; + + act(() => result.current.setIsReduced(newValue)); + + expect(result.current.isReduced).toBe(newValue); + }); + + it('can toggle the value', () => { + const defaultValue = false; + + const { result } = renderHook(() => useReducedMotion(), { + wrapper: createWrapper(MotionProvider, { + attribute: 'aperiam', + hasReducedMotion: defaultValue, + storageKey: 'voluptate', + }), + }); + + expect(result.current.isReduced).toBe(defaultValue); + + act(() => result.current.toggleReducedMotion()); + + expect(result.current.isReduced).toBe(!defaultValue); + }); +}); diff --git a/src/utils/hooks/use-reduced-motion/use-reduced-motion.ts b/src/utils/hooks/use-reduced-motion/use-reduced-motion.ts new file mode 100644 index 0000000..2937b75 --- /dev/null +++ b/src/utils/hooks/use-reduced-motion/use-reduced-motion.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; +import { MotionContext } from '../../providers/motion-provider'; + +export const useReducedMotion = () => { + const { isReduced, setIsReduced, toggleReducedMotion } = + useContext(MotionContext); + + return { isReduced, setIsReduced, toggleReducedMotion }; +}; diff --git a/src/utils/providers/index.ts b/src/utils/providers/index.ts index 640730f..a01200a 100644 --- a/src/utils/providers/index.ts +++ b/src/utils/providers/index.ts @@ -1,2 +1,3 @@ export * from './ackee-provider'; +export * from './motion-provider'; export * from './prism-theme'; diff --git a/src/utils/providers/motion-provider/index.ts b/src/utils/providers/motion-provider/index.ts new file mode 100644 index 0000000..67f493e --- /dev/null +++ b/src/utils/providers/motion-provider/index.ts @@ -0,0 +1 @@ +export * from './motion-provider'; diff --git a/src/utils/providers/motion-provider/motion-provider.test.tsx b/src/utils/providers/motion-provider/motion-provider.test.tsx new file mode 100644 index 0000000..3a02e6f --- /dev/null +++ b/src/utils/providers/motion-provider/motion-provider.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { type FC, useContext } from 'react'; +import { MotionContext, MotionProvider } from './motion-provider'; + +const bodyPrefix = 'Motion is reduced:'; + +const ComponentTest: FC = () => { + const { isReduced } = useContext(MotionContext); + + return ( + <div> + {bodyPrefix} {`${isReduced}`} + </div> + ); +}; + +describe('MotionProvider', () => { + it('uses the default value when the provider is not used', () => { + const defaultValue = false; + + render(<ComponentTest />); + + expect(rtlScreen.getByText(new RegExp(bodyPrefix))).toHaveTextContent( + `${bodyPrefix} ${defaultValue}` + ); + }); + + it('provides the given value to its children and set a matching attribute', () => { + const attribute = 'eius'; + const isReduced = true; + + const { baseElement } = render( + <MotionProvider + attribute={attribute} + storageKey="aperiam" + hasReducedMotion={isReduced} + > + <ComponentTest /> + </MotionProvider> + ); + + expect(rtlScreen.getByText(new RegExp(bodyPrefix))).toHaveTextContent( + `${bodyPrefix} ${isReduced}` + ); + expect(baseElement.parentElement?.getAttribute(`data-${attribute}`)).toBe( + `${isReduced}` + ); + }); +}); diff --git a/src/utils/providers/motion-provider/motion-provider.tsx b/src/utils/providers/motion-provider/motion-provider.tsx new file mode 100644 index 0000000..dfedcaa --- /dev/null +++ b/src/utils/providers/motion-provider/motion-provider.tsx @@ -0,0 +1,83 @@ +import { + type Dispatch, + type FC, + type ReactNode, + type SetStateAction, + createContext, + useMemo, + useCallback, + useEffect, +} from 'react'; +import { getDataAttributeFrom } from '../../helpers'; +import { useLocalStorage } from '../../hooks'; + +type MotionContextProps = { + isReduced: boolean; + setIsReduced: Dispatch<SetStateAction<boolean>>; + toggleReducedMotion: () => void; +}; + +export const MotionContext = createContext<MotionContextProps>({ + isReduced: false, + setIsReduced: (value) => value, + toggleReducedMotion: () => null, +}); + +const validator = (value: unknown): value is boolean => + typeof value === 'boolean'; + +export type MotionProviderProps = { + /** + * The attribute name to append to document root. + */ + attribute: string; + /** + * The provider children. + */ + children?: ReactNode; + /** + * Is reduced motion currently active? + * + * @default false + */ + hasReducedMotion?: boolean; + /** + * The key to use in local storage. + */ + storageKey: string; +}; + +export const MotionProvider: FC<MotionProviderProps> = ({ + attribute, + children, + hasReducedMotion = false, + storageKey, +}) => { + const [isReduced, setIsReduced] = useLocalStorage( + storageKey, + hasReducedMotion, + validator + ); + const dataAttribute = getDataAttributeFrom(attribute); + + useEffect(() => { + if (typeof window !== 'undefined') + document.documentElement.setAttribute(dataAttribute, `${isReduced}`); + + return () => { + document.documentElement.removeAttribute(dataAttribute); + }; + }, [dataAttribute, isReduced]); + + const toggleReducedMotion = useCallback(() => { + setIsReduced((prevState) => !prevState); + }, [setIsReduced]); + + const value: MotionContextProps = useMemo(() => { + return { isReduced, setIsReduced, toggleReducedMotion }; + }, [isReduced, setIsReduced, toggleReducedMotion]); + + return ( + <MotionContext.Provider value={value}>{children}</MotionContext.Provider> + ); +}; diff --git a/tests/utils/index.tsx b/tests/utils/index.tsx index 8766318..f86f6fa 100644 --- a/tests/utils/index.tsx +++ b/tests/utils/index.tsx @@ -5,7 +5,7 @@ import { import { ThemeProvider } from 'next-themes'; import type { FC, ReactElement, ReactNode } from 'react'; import { IntlProvider } from 'react-intl'; -import { AckeeProvider } from '../../src/utils/providers'; +import { AckeeProvider, MotionProvider } from '../../src/utils/providers'; type ProvidersConfig = { children: ReactNode; @@ -31,7 +31,13 @@ const AllTheProviders: FC<ProvidersConfig> = ({ children, locale = 'en' }) => ( storageKey="ackee" tracking="full" > - {children} + <MotionProvider + attribute="reduced-motion" + hasReducedMotion={false} + storageKey="reduced-motion" + > + {children} + </MotionProvider> </AckeeProvider> </ThemeProvider> </IntlProvider> |
