aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-10-27 11:09:38 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:27 +0100
commit757201fdc5c04a3f15504f74bf8ab85bb6018c2b (patch)
tree1adda54704314b24ec81bfdbf0c13acbce2cda87 /src/utils
parent3ab9f0423e97af63da4bf6a13ffd786955bd5b3b (diff)
refactor(hooks,provider): move reduce motion setter
Since the local storage key is not meant to change between the components, it should be set directly inside the app file. So both the local storage and the data attribute should be handle in a provider. I also added a custom document because we need a script to retrieve the stored value in local storage earlier to avoid flashing on hydration.
Diffstat (limited to 'src/utils')
-rw-r--r--src/utils/constants.ts1
-rw-r--r--src/utils/helpers/strings.ts32
-rw-r--r--src/utils/hooks/index.ts1
-rw-r--r--src/utils/hooks/use-reduced-motion/index.ts1
-rw-r--r--src/utils/hooks/use-reduced-motion/use-reduced-motion.test.tsx66
-rw-r--r--src/utils/hooks/use-reduced-motion/use-reduced-motion.ts9
-rw-r--r--src/utils/providers/index.ts1
-rw-r--r--src/utils/providers/motion-provider/index.ts1
-rw-r--r--src/utils/providers/motion-provider/motion-provider.test.tsx50
-rw-r--r--src/utils/providers/motion-provider/motion-provider.tsx83
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>
+ );
+};