diff options
Diffstat (limited to 'src')
27 files changed, 314 insertions, 196 deletions
| 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> +  ); +}; | 
