diff options
Diffstat (limited to 'src/utils')
| -rw-r--r-- | src/utils/constants.ts | 1 | ||||
| -rw-r--r-- | src/utils/helpers/strings.ts | 32 | ||||
| -rw-r--r-- | src/utils/hooks/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-reduced-motion/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/hooks/use-reduced-motion/use-reduced-motion.test.tsx | 66 | ||||
| -rw-r--r-- | src/utils/hooks/use-reduced-motion/use-reduced-motion.ts | 9 | ||||
| -rw-r--r-- | src/utils/providers/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/providers/motion-provider/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/providers/motion-provider/motion-provider.test.tsx | 50 | ||||
| -rw-r--r-- | src/utils/providers/motion-provider/motion-provider.tsx | 83 |
10 files changed, 233 insertions, 12 deletions
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> + ); +}; |
