├── .babelrc ├── .prettierrc ├── .npmignore ├── .eslintrc ├── __tests__ ├── .eslintrc ├── initialize.test.js └── index.test.js ├── .editorconfig ├── .travis.yml ├── LICENSE ├── .gitignore ├── types └── index.d.ts ├── noflash.js.txt ├── src ├── index.js └── initialize.js ├── package.json ├── .all-contributorsrc └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | src 3 | .editorconfig 4 | .eslintrc 5 | .babelrc 6 | __tests__ 7 | coverage 8 | .prettierrc 9 | .travis.yml 10 | .all-contributorsrc 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex", 3 | "plugins": ["react-hooks"], 4 | "rules": { 5 | "react-hooks/rules-of-hooks": 2, 6 | "react-hooks/exhaustive-deps": 2 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "amex/test", 3 | "rules": { 4 | "react/prop-types": 0, 5 | "react/button-has-type": 0, 6 | "react/jsx-filename-extension": 0 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | 6 | jobs: 7 | include: 8 | - stage: test 9 | script: npm t 10 | - stage: npm release 11 | if: branch = master 12 | deploy: 13 | provider: npm 14 | email: $NPM_EMAIL 15 | api_key: $NPM_TOKEN 16 | skip_cleanup: true 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present Donavon West 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | .DS_Store 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | # lib 65 | lib/ 66 | dist/ 67 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@fisch0920/use-dark-mode' { 2 | /** 3 | * A config object allowing you to specify certain aspects of `useDarkMode` 4 | */ 5 | export interface DarkModeConfig { 6 | classNameDark?: string; // A className to set "dark mode". Default = "dark-mode". 7 | classNameLight?: string; // A className to set "light mode". Default = "light-mode". 8 | element?: HTMLElement; // The element to apply the className. Default = `document.body` 9 | onChange?: (val?: boolean) => void; // Overide the default className handler with a custom callback. 10 | storageKey?: string; // Specify the `localStorage` key. Default = "darkMode". Set to `null` to disable persistent storage. 11 | storageProvider?: WindowLocalStorage; // A storage provider. Default = `localStorage`. 12 | global?: Window; // The global object. Default = `window`. 13 | } 14 | 15 | /** 16 | * An object returned from a call to `useDarkMode`. 17 | */ 18 | export interface DarkMode { 19 | readonly value: boolean; 20 | enable: () => void; 21 | disable: () => void; 22 | toggle: () => void; 23 | } 24 | 25 | /** 26 | * A custom React Hook to help you implement a "dark mode" component for your application. 27 | */ 28 | export default function useDarkMode( 29 | initialState?: boolean, 30 | config?: DarkModeConfig 31 | ): DarkMode; 32 | } 33 | -------------------------------------------------------------------------------- /noflash.js.txt: -------------------------------------------------------------------------------- 1 | // Insert this script in your index.html right after the
tag. 2 | // This will help to prevent a flash if dark mode is the default. 3 | 4 | (function() { 5 | // Change these if you use something different in your hook. 6 | var storageKey = 'darkMode'; 7 | var classNameDark = 'dark-mode'; 8 | var classNameLight = 'light-mode'; 9 | 10 | function setClassOnDocumentBody(darkMode) { 11 | document.body.classList.add(darkMode ? classNameDark : classNameLight); 12 | document.body.classList.remove(darkMode ? classNameLight : classNameDark); 13 | } 14 | 15 | var preferDarkQuery = '(prefers-color-scheme: dark)'; 16 | var mql = window.matchMedia(preferDarkQuery); 17 | var supportsColorSchemeQuery = mql.media === preferDarkQuery; 18 | var localStorageTheme = null; 19 | try { 20 | localStorageTheme = localStorage.getItem(storageKey); 21 | } catch (err) {} 22 | var localStorageExists = localStorageTheme !== null; 23 | if (localStorageExists) { 24 | localStorageTheme = JSON.parse(localStorageTheme); 25 | } 26 | 27 | // Determine the source of truth 28 | if (localStorageExists) { 29 | // source of truth from localStorage 30 | setClassOnDocumentBody(localStorageTheme); 31 | } else if (supportsColorSchemeQuery) { 32 | // source of truth from system 33 | setClassOnDocumentBody(mql.matches); 34 | localStorage.setItem(storageKey, mql.matches); 35 | } else { 36 | // source of truth from document.body 37 | var isDarkMode = document.body.classList.contains(classNameDark); 38 | localStorage.setItem(storageKey, JSON.stringify(isDarkMode)); 39 | } 40 | })(); 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useMemo } from 'react'; 2 | import useEventListener from '@use-it/event-listener'; 3 | 4 | import initialize from './initialize'; 5 | 6 | const useDarkMode = ( 7 | initialValue = false, 8 | { 9 | element, 10 | classNameDark, 11 | classNameLight, 12 | onChange, 13 | storageKey = 'darkMode', 14 | storageProvider, 15 | global, 16 | } = {} 17 | ) => { 18 | const { 19 | usePersistedDarkModeState, 20 | getDefaultOnChange, 21 | getInitialValue, 22 | mediaQueryEventTarget, 23 | } = useMemo( 24 | () => initialize(storageKey, storageProvider, global), 25 | [storageKey, storageProvider, global] 26 | ); 27 | 28 | const [state, setState] = usePersistedDarkModeState(getInitialValue(initialValue)); 29 | 30 | const stateChangeCallback = useMemo( 31 | () => onChange || getDefaultOnChange(element, classNameDark, classNameLight), 32 | [onChange, element, classNameDark, classNameLight, getDefaultOnChange] 33 | ); 34 | 35 | // Call the onChange handler 36 | useEffect(() => { 37 | stateChangeCallback(state); 38 | }, [stateChangeCallback, state]); 39 | 40 | // Listen for media changes and set state. 41 | useEventListener( 42 | 'change', 43 | ({ matches }) => setState(matches), 44 | mediaQueryEventTarget 45 | ); 46 | 47 | return { 48 | value: state, 49 | enable: useCallback(() => setState(true), [setState]), 50 | disable: useCallback(() => setState(false), [setState]), 51 | toggle: useCallback(() => setState(current => !current), [setState]), 52 | }; 53 | }; 54 | 55 | export default useDarkMode; 56 | -------------------------------------------------------------------------------- /src/initialize.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import createPersistedState from 'use-persisted-state'; 3 | 4 | const noop = () => {}; 5 | 6 | const mockElement = { 7 | classList: { 8 | add: noop, 9 | remove: noop, 10 | }, 11 | }; 12 | 13 | const preferDarkQuery = '(prefers-color-scheme: dark)'; 14 | 15 | const initialize = (storageKey, storageProvider, glbl = global) => { 16 | const usePersistedDarkModeState = storageKey 17 | ? createPersistedState(storageKey, storageProvider) 18 | : useState; 19 | 20 | const mql = glbl.matchMedia ? glbl.matchMedia(preferDarkQuery) : {}; 21 | 22 | const mediaQueryEventTarget = { 23 | addEventListener: (_, handler) => mql.addListener && mql.addListener(handler), 24 | removeEventListener: (_, handler) => mql.removeListener && mql.removeListener(handler), 25 | }; 26 | 27 | const isColorSchemeQuerySupported = mql.media === preferDarkQuery; 28 | 29 | const getInitialValue = usersInitialState => ( 30 | isColorSchemeQuerySupported ? mql.matches : usersInitialState 31 | ); 32 | 33 | // Mock element if SSR else real body element. 34 | const defaultElement = (glbl.document && glbl.document.body) || mockElement; 35 | 36 | const getDefaultOnChange = ( 37 | element = defaultElement, 38 | classNameDark = 'dark-mode', 39 | classNameLight = 'light-mode' 40 | ) => (val) => { 41 | element.classList.add(val ? classNameDark : classNameLight); 42 | element.classList.remove(val ? classNameLight : classNameDark); 43 | }; 44 | 45 | return { 46 | usePersistedDarkModeState, 47 | getDefaultOnChange, 48 | mediaQueryEventTarget, 49 | getInitialValue, 50 | }; 51 | }; 52 | 53 | export default initialize; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fisch0920/use-dark-mode", 3 | "publishConfig": { 4 | "tag": "latest", 5 | "access": "public" 6 | }, 7 | "version": "2.4.0", 8 | "description": "A custom React Hook to help you implement a \"dark mode\" component.", 9 | "main": "dist/use-dark-mode.js", 10 | "umd:main": "dist/use-dark-mode.umd.js", 11 | "module": "dist/use-dark-mode.m.js", 12 | "source": "src/index.js", 13 | "types": "types/index.d.ts", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/transitive-bullshit/use-dark-mode.git" 18 | }, 19 | "scripts": { 20 | "prepublishOnly": "npm run build", 21 | "lint": "eslint src", 22 | "test": "jest --verbose --coverage --silent", 23 | "test:watch": "jest --watch --runInBand --silent", 24 | "prebuild": "npm run lint && npm t && rimraf dist", 25 | "build": "microbundle -o dist/ --sourcemap false --target web", 26 | "dev": "microbundle watch -o dist/ --sourcemap false --compress false" 27 | }, 28 | "keywords": [ 29 | "react-hooks", 30 | "hooks", 31 | "react", 32 | "utils", 33 | "lib", 34 | "dark-mode" 35 | ], 36 | "author": "donavon", 37 | "devDependencies": { 38 | "@babel/core": "^7.2.2", 39 | "@babel/preset-env": "^7.2.3", 40 | "@babel/preset-react": "^7.0.0", 41 | "babel-core": "^7.0.0-bridge.0", 42 | "babel-jest": "^23.6.0", 43 | "eslint": "^5.10.0", 44 | "eslint-config-amex": "^9.0.0", 45 | "eslint-plugin-react-hooks": "^1.2.0", 46 | "jest": "^23.6.0", 47 | "jest-dom": "^3.0.0", 48 | "microbundle": "^0.9.0", 49 | "react": "^16.8.0", 50 | "react-dom": "^16.8.0", 51 | "react-testing-library": "^5.5.0", 52 | "rimraf": "^2.6.2" 53 | }, 54 | "peerDependencies": { 55 | "react": ">=16.8" 56 | }, 57 | "jest": { 58 | "coverageThreshold": { 59 | "global": { 60 | "branches": 100, 61 | "functions": 100, 62 | "lines": 100, 63 | "statements": 100 64 | } 65 | } 66 | }, 67 | "dependencies": { 68 | "@use-it/event-listener": "^0.1.2", 69 | "use-persisted-state": "^0.3.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "use-dark-mode", 3 | "projectOwner": "donavon", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "contributors": [ 12 | { 13 | "login": "donavon", 14 | "name": "Donavon West", 15 | "avatar_url": "https://avatars3.githubusercontent.com/u/887639?v=4", 16 | "profile": "http://donavon.com", 17 | "contributions": [ 18 | "infra", 19 | "test", 20 | "example", 21 | "ideas", 22 | "maintenance", 23 | "review", 24 | "tool", 25 | "code" 26 | ] 27 | }, 28 | { 29 | "login": "revelcw", 30 | "name": "Revel Carlberg West", 31 | "avatar_url": "https://avatars2.githubusercontent.com/u/29359616?v=4", 32 | "profile": "https://github.com/revelcw", 33 | "contributions": [ 34 | "ideas" 35 | ] 36 | }, 37 | { 38 | "login": "Andarist", 39 | "name": "Mateusz Burzyński", 40 | "avatar_url": "https://avatars2.githubusercontent.com/u/9800850?v=4", 41 | "profile": "https://github.com/Andarist", 42 | "contributions": [ 43 | "code" 44 | ] 45 | }, 46 | { 47 | "login": "wKovacs64", 48 | "name": "Justin Hall", 49 | "avatar_url": "https://avatars1.githubusercontent.com/u/1288694?v=4", 50 | "profile": "https://github.com/wKovacs64", 51 | "contributions": [ 52 | "code" 53 | ] 54 | }, 55 | { 56 | "login": "fxbabys", 57 | "name": "Jeremy", 58 | "avatar_url": "https://avatars1.githubusercontent.com/u/24556921?v=4", 59 | "profile": "https://github.com/fxbabys", 60 | "contributions": [ 61 | "userTesting", 62 | "bug" 63 | ] 64 | }, 65 | { 66 | "login": "janosh", 67 | "name": "Janosh Riebesell", 68 | "avatar_url": "https://avatars0.githubusercontent.com/u/30958850?v=4", 69 | "profile": "http://janosh.io", 70 | "contributions": [ 71 | "doc" 72 | ] 73 | }, 74 | { 75 | "login": "hipstersmoothie", 76 | "name": "Andrew Lisowski", 77 | "avatar_url": "https://avatars3.githubusercontent.com/u/1192452?v=4", 78 | "profile": "http://hipstersmoothie.com", 79 | "contributions": [ 80 | "doc" 81 | ] 82 | }, 83 | { 84 | "login": "jorgegonzalez", 85 | "name": "Jorge Gonzalez", 86 | "avatar_url": "https://avatars2.githubusercontent.com/u/12901172?v=4", 87 | "profile": "https://jorgegonzalez.io", 88 | "contributions": [ 89 | "code" 90 | ] 91 | } 92 | ], 93 | "contributorsPerLine": 7, 94 | "skipCi": true 95 | } 96 | -------------------------------------------------------------------------------- /__tests__/initialize.test.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import 'jest-dom/extend-expect'; 3 | 4 | import initialize from '../src/initialize'; 5 | 6 | const noop = () => {}; 7 | 8 | const createTestElement = arr => ({ 9 | classList: { 10 | add: (className) => { 11 | arr.push({ method: 'add', className }); 12 | }, 13 | remove: (className) => { 14 | arr.push({ method: 'remove', className }); 15 | }, 16 | }, 17 | }); 18 | 19 | const mockedWindowSupported = { 20 | matchMedia: query => ({ 21 | addListener: noop, 22 | removeListener: noop, 23 | media: query, 24 | matches: true, 25 | }), 26 | }; 27 | 28 | const mockedWindowNotSupported = { 29 | matchMedia: () => ({ 30 | addListener: noop, 31 | removeListener: noop, 32 | media: 'not all', 33 | matches: false, 34 | }), 35 | }; 36 | 37 | const testSuite = (g = global, { getInitialValue, getDefaultOnChange, mediaQueryEventTarget }) => { 38 | const global = g; 39 | 40 | describe('getInitialValue', () => { 41 | test('import { getInitialValue } from "./utils"', () => { 42 | expect(typeof getInitialValue).toBe('function'); 43 | }); 44 | test('is passed an initial value and returns that same value if isColorSchemeQuerySupported', () => { 45 | const mql = global.matchMedia 46 | ? global.matchMedia('(prefers-color-scheme: dark)') 47 | : {}; 48 | const isColorSchemeQuerySupported = mql.media === '(prefers-color-scheme: dark)'; 49 | 50 | // const isColorSchemeQuerySupported = !!global.matchMedia; 51 | 52 | if (isColorSchemeQuerySupported) { 53 | expect(getInitialValue('foo')).toBe(true); 54 | } else { 55 | expect(getInitialValue('foo')).toBe('foo'); 56 | } 57 | }); 58 | }); 59 | 60 | describe('mediaQueryEventTarget', () => { 61 | test('is an object that supports an addEventListener method', () => { 62 | expect(typeof mediaQueryEventTarget.addEventListener).toBe('function'); 63 | expect(mediaQueryEventTarget.addEventListener()).toBeUndefined(); 64 | }); 65 | test('is an object that supports an removeEventListener method', () => { 66 | expect(typeof mediaQueryEventTarget.removeEventListener).toBe('function'); 67 | expect(mediaQueryEventTarget.removeEventListener()).toBeUndefined(); 68 | }); 69 | }); 70 | 71 | describe('getDefaultOnChange', () => { 72 | test('is a function', () => { 73 | expect(typeof getDefaultOnChange).toBe('function'); 74 | }); 75 | test('you pass it `element`, `classNameDark`, and `classNameLight`', () => { 76 | const test = []; 77 | const mockElement = createTestElement(test); 78 | const defaultOnChange = getDefaultOnChange(mockElement, 'foo', 'bar'); 79 | expect(typeof defaultOnChange).toBe('function'); 80 | }); 81 | test('these are all optional', () => { 82 | const defaultOnChange = getDefaultOnChange(); 83 | expect(typeof defaultOnChange).toBe('function'); 84 | }); 85 | test('it returns a function', () => { 86 | expect(typeof getDefaultOnChange()).toBe('function'); 87 | }); 88 | test('if you pass it `false`, the "light mode" class is added to the element', () => { 89 | const calls = []; 90 | const mockElement = createTestElement(calls); 91 | const defaultOnChange = getDefaultOnChange(mockElement, 'dark', 'light'); 92 | defaultOnChange(false); 93 | 94 | expect(calls.length).toBe(2); 95 | expect(calls[0]).toEqual({ method: 'add', className: 'light' }); 96 | expect(calls[1]).toEqual({ method: 'remove', className: 'dark' }); 97 | }); 98 | test('if you pass it `true`, the "dark mode" class is added to the element', () => { 99 | const calls = []; 100 | const mockElement = createTestElement(calls); 101 | const defaultOnChange = getDefaultOnChange(mockElement, 'dark', 'light'); 102 | defaultOnChange(true); 103 | 104 | expect(calls.length).toBe(2); 105 | expect(calls[0]).toEqual({ method: 'add', className: 'dark' }); 106 | expect(calls[1]).toEqual({ method: 'remove', className: 'light' }); 107 | }); 108 | }); 109 | }; 110 | 111 | describe('calling initialize', () => { 112 | test('with key)', () => { 113 | const { usePersistedDarkModeState } = initialize('key'); 114 | expect(usePersistedDarkModeState).not.toBe(useState); 115 | }); 116 | 117 | test('without key)', () => { 118 | const { usePersistedDarkModeState } = initialize(); 119 | expect(usePersistedDarkModeState).toBe(useState); 120 | }); 121 | }); 122 | 123 | describe('initialize with default', () => { 124 | const global = undefined; 125 | testSuite(global, initialize(null, null, global)); 126 | }); 127 | describe('initialize with an empty object (SSR)', () => { 128 | const global = {}; 129 | testSuite(global, initialize(null, null, global)); 130 | }); 131 | describe('initialize with a mock `window` (browser with dark mode support)', () => { 132 | const global = mockedWindowSupported; 133 | testSuite(global, initialize(null, null, global)); 134 | }); 135 | describe('initialize with a mock `window` (browser without dark mode support)', () => { 136 | const global = mockedWindowNotSupported; 137 | testSuite(global, initialize(null, null, global)); 138 | }); 139 | -------------------------------------------------------------------------------- /__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | import { testHook, act, cleanup } from 'react-testing-library'; 2 | import 'jest-dom/extend-expect'; 3 | 4 | import useDarkMode from '../src'; 5 | 6 | afterEach(cleanup); 7 | 8 | const createTestElement = arr => ({ 9 | classList: { 10 | add: (className) => { 11 | arr.push({ method: 'add', className }); 12 | }, 13 | remove: (className) => { 14 | arr.push({ method: 'remove', className }); 15 | }, 16 | }, 17 | }); 18 | 19 | describe('useDarkMode without onChange', () => { 20 | test('initializing with a default value will return false', () => { 21 | let value; 22 | testHook(() => { 23 | ({ value } = useDarkMode(undefined, { storageKey: null })); 24 | }); 25 | expect(value).toBe(false); 26 | }); 27 | 28 | test('initializing with a value of false will return false', () => { 29 | let value; 30 | testHook(() => { 31 | ({ value } = useDarkMode(false, { storageKey: null })); 32 | }); 33 | expect(value).toBe(false); 34 | }); 35 | 36 | test('initializing with a initial value of true will return true', () => { 37 | let value; 38 | testHook(() => { 39 | ({ value } = useDarkMode(true, { storageKey: null })); 40 | }); 41 | expect(value).toBe(true); 42 | }); 43 | 44 | test('calling `enable` will return `true`', () => { 45 | let value; 46 | let enable; 47 | testHook(() => { 48 | ({ value, enable } = useDarkMode(false, { storageKey: null })); 49 | }); 50 | act(enable); 51 | expect(value).toBe(true); 52 | }); 53 | 54 | test('calling `disable` will return `true`', () => { 55 | let value; 56 | let disable; 57 | testHook(() => { 58 | ({ value, disable } = useDarkMode(true, { storageKey: null })); 59 | }); 60 | act(disable); 61 | expect(value).toBe(false); 62 | }); 63 | 64 | test('calling `toggle` will return flip from `true` to `false` and vice versa', () => { 65 | let value; 66 | let toggle; 67 | testHook(() => { 68 | ({ value, toggle } = useDarkMode(true, { storageKey: null })); 69 | }); 70 | act(toggle); 71 | expect(value).toBe(false); 72 | act(toggle); 73 | expect(value).toBe(true); 74 | }); 75 | 76 | test('a media change to "light mode" will return true', () => { 77 | let callback; 78 | 79 | const mockGlobal = { 80 | matchMedia: media => ({ 81 | media, 82 | match: false, 83 | addListener: (handler) => { callback = handler; }, 84 | removeistener: () => {}, 85 | }), 86 | }; 87 | 88 | let value; 89 | testHook(() => { 90 | ({ value } = useDarkMode(false, { storageKey: null, global: mockGlobal })); 91 | }); 92 | callback({ matches: true }); 93 | expect(value).toBe(true); 94 | }); 95 | 96 | test('a media change to "dark mode" will return false', () => { 97 | let callback; 98 | 99 | const mockGlobal = { 100 | matchMedia: media => ({ 101 | media, 102 | match: true, 103 | addListener: (handler) => { callback = handler; }, 104 | removeistener: () => {}, 105 | }), 106 | }; 107 | 108 | let value; 109 | testHook(() => { 110 | ({ value } = useDarkMode(true, { storageKey: null, global: mockGlobal })); 111 | }); 112 | callback({ matches: false }); 113 | expect(value).toBe(false); 114 | }); 115 | }); 116 | 117 | describe('useDarkMode with onChange', () => { 118 | test('calling `enable` will call handler with `true`', () => { 119 | let enable; 120 | const handler = jest.fn(); 121 | testHook(() => { 122 | ({ enable } = useDarkMode(false, { storageKey: null, onChange: handler })); 123 | }); 124 | act(enable); 125 | expect(handler).toHaveBeenCalledWith(true); 126 | }); 127 | test('calling `disable` will call handler with `true`', () => { 128 | let disable; 129 | const handler = jest.fn(); 130 | testHook(() => { 131 | ({ disable } = useDarkMode(true, { storageKey: null, onChange: handler })); 132 | }); 133 | act(disable); 134 | expect(handler).toHaveBeenCalledWith(false); 135 | }); 136 | }); 137 | 138 | describe('useDarkMode accepts a default `config`', () => { 139 | test('calling `disable` will call handler with `true`', () => { 140 | let value; 141 | testHook(() => { 142 | ({ value } = useDarkMode(true)); 143 | }); 144 | expect(value).toBe(true); 145 | }); 146 | }); 147 | 148 | describe('useDarkMode and default `onChange`', () => { 149 | test('`classNameDark` and `classNameDark` default', () => { 150 | const calls = []; 151 | const mockElement = createTestElement(calls); 152 | 153 | testHook(() => { 154 | (useDarkMode(true, { 155 | storageKey: null, 156 | element: mockElement, 157 | })); 158 | }); 159 | expect(calls.length).toBe(2); 160 | expect(calls[0]).toEqual({ method: 'add', className: 'dark-mode' }); 161 | expect(calls[1]).toEqual({ method: 'remove', className: 'light-mode' }); 162 | }); 163 | 164 | test('`classNameDark` and `classNameDark` can be specified in `config`', () => { 165 | const calls = []; 166 | const mockElement = createTestElement(calls); 167 | 168 | let toggle; 169 | testHook(() => { 170 | ({ toggle } = useDarkMode(true, { 171 | storageKey: null, 172 | element: mockElement, 173 | classNameDark: 'd', 174 | classNameLight: 'l', 175 | })); 176 | }); 177 | expect(calls.length).toBe(2); 178 | expect(calls[0]).toEqual({ method: 'add', className: 'd' }); 179 | expect(calls[1]).toEqual({ method: 'remove', className: 'l' }); 180 | 181 | act(toggle); 182 | expect(calls.length).toBe(4); 183 | expect(calls[2]).toEqual({ method: 'add', className: 'l' }); 184 | expect(calls[3]).toEqual({ method: 'remove', className: 'd' }); 185 | }); 186 | 187 | test('you can specify a custom `storageProvider` and a `storageKey', () => { 188 | const data = []; 189 | const mockProvider = { 190 | getItem: () => null, 191 | setItem: (key, value) => { data.push([key, value]); }, 192 | }; 193 | 194 | let toggle; 195 | testHook(() => { 196 | ({ toggle } = useDarkMode(true, { 197 | storageKey: 'key', 198 | storageProvider: mockProvider, 199 | onChange: () => {}, 200 | })); 201 | }); 202 | expect(data.length).toBe(1); 203 | expect(data[0]).toEqual(['key', 'true']); 204 | 205 | act(toggle); 206 | 207 | expect(data.length).toBe(2); 208 | expect(data[1]).toEqual(['key', 'false']); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @fisch0920/use-dark-mode 2 | 3 | > NOTE: this is a fork of [use-dark-mode](https://github.com/donavon/use-dark-mode) by [donovan](https://github.com/donavon) which adds some much needed maintenance support for React 17 and 18. 4 | 5 | A custom [React Hook](https://reactjs.org/docs/hooks-overview.html) to help you implement a "dark mode" component for your application. 6 | The user setting persists to `localStorage`. 7 | 8 | ❤️ it? ⭐️ it on [GitHub](https://github.com/transitive-bullshit/use-dark-mode/stargazers) 9 | 10 | [](https://badge.fury.io/js/@fisch0920/use-dark-mode) [](#contributors) 11 | 12 |  13 | 14 | `useDarkMode` works in one of two ways: 15 | 16 | 1. By toggling a CSS class on whatever element you specify (defaults to `document.body`). 17 | You then setup your CSS to display different views based on the presence of the selector. For example, the following CSS is used in the demo app to ease the background color in/out of dark mode. 18 | 19 | ```css 20 | body.light-mode { 21 | background-color: #fff; 22 | color: #333; 23 | transition: background-color 0.3s ease; 24 | } 25 | body.dark-mode { 26 | background-color: #1a1919; 27 | color: #999; 28 | } 29 | ``` 30 | 31 | 2. If you don't use global classes, you can specify an `onChange` handler and take care of the implementation of switching to dark mode yourself. 32 | 33 | ## New in Version 2.x 34 | 35 | - `useDarkMode` now persists between sessions. It stores the user setting in 36 | `localStorage`. 37 | 38 | - It shares dark mode state with all other `useDarkMode` components on the page. 39 | 40 | - It shares dark mode state with all other tabs/browser windows. 41 | 42 | - The initial dark mode is queried from the system. Note: this requires a browser that supports the `prefers-color-scheme: dark` media query 43 | ([currently Chrome, Firefox, Safari and Edge](https://caniuse.com/#search=prefers-color-scheme)) 44 | and a system that supports dark mode, such as macOS Mojave. 45 | 46 | - Changing the system dark mode state will also change the state of `useDarkMode` 47 | (i.e, change to light mode in the system will change to light mode in your app). 48 | 49 | - Support for Server Side Rendering (SSR) in version 2.2 and above. 50 | 51 | ## Requirement 52 | 53 | To use `@fisch0920/use-dark-mode`, you must use `react@16.8.0` or greater which includes Hooks. 54 | 55 | ## Installation 56 | 57 | ```sh 58 | $ npm i @fisch0920/use-dark-mode 59 | ``` 60 | 61 | ## Usage 62 | 63 | ```js 64 | const darkMode = useDarkMode(initialState, darkModeConfig); 65 | ``` 66 | 67 | ### Parameters 68 | 69 | You pass `useDarkMode` an `initialState` (a boolean specifying whether it should be in dark mode 70 | by default) and an optional `darkModeConfig` object. The configuration object may contain the following keys. 71 | 72 | | Key | Description | 73 | | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 74 | | `classNameDark` | The class to apply. Default = `dark-mode`. | 75 | | `classNameLight` | The class to apply. Default = `light-mode`. | 76 | | `element` | The element to apply the class name. Default = `document.body`. | 77 | | `onChange` | A function that will be called when the dark mode value changes and it is safe to access the DOM (i.e. it is called from within a `useEffect`). If you specify `onChange` then `classNameDark`, `classNameLight`, and `element` are ignored (i.e. no classes are automatically placed on the DOM). You have full control! | 78 | | `storageKey` | A string that will be used by the `storageProvider` to persist the dark mode value. If you specify a value of `null`, nothing will be persisted. Default = `darkMode`. | 79 | | `storageProvider` | A storage provider. Default = `localStorage`. You will generally never need to change this value. | 80 | 81 | ### Return object 82 | 83 | A `darkMode` object is returned with the following properties. 84 | 85 | | Key | Description | 86 | | :---------- | :------------------------------------------------------ | 87 | | `value` | A boolean containing the current state of dark mode. | 88 | | `enable()` | A function that allows you to set dark mode to `true`. | 89 | | `disable()` | A function that allows you to set dark mode to `false`. | 90 | | `toggle()` | A function that allows you to toggle dark mode. | 91 | 92 | Note that because the methods don't require any parameters, you can call them 93 | direcly from an `onClick` handler from a button, for example 94 | (i.e., no lambda function is required). 95 | 96 | ## Example 97 | 98 | Here is a simple component that uses `useDarkMode` to provide a dark mode toggle control. 99 | If dark mode is selected, the CSS class `dark-mode` is applied to `document.body` and is removed 100 | when de-selected. 101 | 102 | ```jsx 103 | import React from 'react'; 104 | import useDarkMode from '@fisch0920/use-dark-mode'; 105 | 106 | import Toggle from './Toggle'; 107 | 108 | const DarkModeToggle = () => { 109 | const darkMode = useDarkMode(false); 110 | 111 | return ( 112 |