diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a98f8586..2b8c6c5e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,6 +6,7 @@ export * from './useBreakpoints/useBreakpoints'; export * from './useBrowserLanguage/useBrowserLanguage'; export * from './useClickOutside/useClickOutside'; export * from './useClipboard/useClipboard'; +export * from './useColorMode/useColorMode'; export * from './useCounter/useCounter'; export * from './useCssVar/useCssVar'; export * from './useDebounceCallback/useDebounceCallback'; diff --git a/src/hooks/useColorMode/useColorMode.demo.tsx b/src/hooks/useColorMode/useColorMode.demo.tsx new file mode 100644 index 00000000..416ff9e4 --- /dev/null +++ b/src/hooks/useColorMode/useColorMode.demo.tsx @@ -0,0 +1,19 @@ +import { useColorMode } from './useColorMode'; + +const Demo = () => { + const colorMode = useColorMode(); + + const toggleColorMode = () => + colorMode.set( + colorMode.value === 'dark' ? 'light' : colorMode.value === 'light' ? 'auto' : 'dark' + ); + + return ( + <> + +

Click to change the color mode

+ + ); +}; + +export default Demo; diff --git a/src/hooks/useColorMode/useColorMode.ts b/src/hooks/useColorMode/useColorMode.ts new file mode 100644 index 00000000..6710b2ce --- /dev/null +++ b/src/hooks/useColorMode/useColorMode.ts @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; + +const CSS_DISABLE_TRANS = ` + *, *::before, *::after { + -webkit-transition: none !important; + -moz-transition: none !important; + -o-transition: none !important; + -ms-transition: none !important; + transition: none !important; + } +`; + +export type BasicColorMode = 'auto' | 'dark' | 'light'; + +/** The use color mode options */ +export interface UseColorModeOptions { + /** HTML attribute applying the target element */ + attribute?: string; + /** Disable transition on switch */ + disableTransition?: boolean; + /** The initial color mode */ + initialValue?: BasicColorMode | MODE; + /** Prefix when adding value to the attribute */ + modes?: Record; + /** CSS Selector for the target element applying to */ + selector?: string; + /** Storage object, can be localStorage or sessionStorage */ + storage?: 'localStorage' | 'sessionStorage'; + /** Key to persist the data into localStorage/sessionStorage. Pass `null` to disable persistence */ + storageKey?: string | null; + /**A custom handler for handle the updates. When specified, the default behavior will be overridden */ + onChanged?: ( + mode: BasicColorMode | MODE, + defaultHandler: (mode: BasicColorMode | MODE) => void + ) => void; +} + +/** The use color mode return type */ +export interface UseColorModeReturn { + /** The value of the auto mode */ + auto: BasicColorMode; + /** The current color mode value */ + value: BasicColorMode | MODE; + /** Function to set the color mode */ + set: (mode: BasicColorMode | MODE) => void; +} + +/** + * @name useColorMode + * @description - Hook for get and set color mode (dark / light / customs) with auto data persistence. + * @category Browser + * + * @param {UseColorModeOptions} options The options for configuring color mode hook. + * @returns {UseColorModeReturn} The object containing the current color mode and a function to set the color mode. + */ +export const useColorMode = ( + options?: UseColorModeOptions +) => { + const { + selector = 'html', + attribute = 'class', + disableTransition = true, + initialValue = 'auto', + storageKey = 'reactuse-color-scheme', + modes = {}, + storage: _storage = 'localStorage', + onChanged + } = options ?? {}; + + const storage = _storage === 'sessionStorage' ? sessionStorage : localStorage; + + const [value, setValue] = useState( + storageKey + ? (storage.getItem(storageKey) as BasicColorMode | MODE | null) || initialValue + : initialValue + ); + + const updateHTMLAttrs = (mode: string) => { + const element = document.querySelector(selector); + if (!element) return; + + const modeClasses = [...Object.values(modes), 'auto', 'dark', 'light'] as ( + | BasicColorMode + | MODE + )[]; + + if (attribute === 'class') { + element.classList.remove(...modeClasses); + element.classList.add(mode); + } else { + element.setAttribute(attribute, mode); + } + + if (disableTransition) { + const style = document.createElement('style'); + style.textContent = CSS_DISABLE_TRANS; + + document.head.appendChild(style); + + (() => getComputedStyle(style).opacity)(); + + document.head.removeChild(style); + } + }; + + const auto = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + + useEffect(() => { + const mode = value !== 'auto' ? value : auto; + + if (storageKey) storage.setItem(storageKey, value); + + const defaultOnChanged = (mode: BasicColorMode | MODE) => updateHTMLAttrs(mode); + + onChanged ? onChanged(mode, defaultOnChanged) : defaultOnChanged(mode); + }, [value, storage, storageKey, onChanged]); + + return { value, auto, set: setValue }; +};