aboutsummaryrefslogtreecommitdiffstats
path: root/src/utils
diff options
context:
space:
mode:
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>
+ );
+};