|
1 | | -import React, { JSX, useEffect } from "react" |
| 1 | +import React, { JSX, useEffect, useState, useSyncExternalStore } from "react" |
2 | 2 | import { ComputerIcon, MoonIcon, SunIcon } from "./icons.js" |
3 | 3 | import { Button, ButtonProps, Tooltip } from "@heroui/react" |
4 | 4 | import { tst } from "../utils/overrides.js" |
5 | 5 |
|
6 | | -export type DarkMode = "dark" | "light" | "system" |
7 | | - |
8 | | -interface MyComponentProps extends ButtonProps { |
9 | | - mode: DarkMode |
10 | | - onModeChange: (newMode: DarkMode) => void |
| 6 | +const modeSelections = ["system", "light", "dark"] |
| 7 | +type ModeSelection = (typeof modeSelections)[number] |
| 8 | +const icons: Record<ModeSelection, JSX.Element> = { |
| 9 | + system: <ComputerIcon className="size-6 inline" />, |
| 10 | + light: <SunIcon className="size-6 inline" />, |
| 11 | + dark: <MoonIcon className="size-6 inline" />, |
11 | 12 | } |
12 | 13 |
|
13 | | -const icons: { name: DarkMode; icon: JSX.Element }[] = [ |
14 | | - { name: "system", icon: <ComputerIcon className="size-6 inline" /> }, |
15 | | - { name: "light", icon: <SunIcon className="size-6 inline" /> }, |
16 | | - { name: "dark", icon: <MoonIcon className="size-6 inline" /> }, |
17 | | -] |
| 14 | +export function useDarkModeSelection(): [ |
| 15 | + boolean, |
| 16 | + ModeSelection | undefined, |
| 17 | + React.Dispatch<React.SetStateAction<ModeSelection | undefined>>, |
| 18 | +] { |
| 19 | + const [modeSelection, setModeSelection] = useState<ModeSelection | undefined>(undefined) |
18 | 20 |
|
19 | | -export function defaultDarkMode(): DarkMode { |
20 | | - const storedDarkModeSelect = localStorage.getItem("darkModeSelect") |
| 21 | + const isSystemDark = useSyncExternalStore<boolean>( |
| 22 | + (callBack) => { |
| 23 | + const mql = window.matchMedia("(prefers-color-scheme: dark)") |
| 24 | + mql.addEventListener("change", callBack) |
| 25 | + return () => { |
| 26 | + mql.removeEventListener("change", callBack) |
| 27 | + } |
| 28 | + }, |
| 29 | + () => { |
| 30 | + return window.matchMedia("(prefers-color-scheme: dark)").matches |
| 31 | + }, |
| 32 | + () => false, |
| 33 | + ) |
21 | 34 |
|
22 | | - if (storedDarkModeSelect !== null && ["light", "dark", "system"].includes(storedDarkModeSelect)) { |
23 | | - return storedDarkModeSelect as DarkMode |
24 | | - } else { |
25 | | - return "system" |
26 | | - } |
27 | | -} |
| 35 | + useEffect(() => { |
| 36 | + if (modeSelection) { |
| 37 | + localStorage.setItem("darkModeSelect", modeSelection) |
| 38 | + } |
| 39 | + }, [modeSelection]) |
28 | 40 |
|
29 | | -export function shouldBeDark(mode: DarkMode): boolean { |
30 | | - // when matchMedia not available (e.g. in tests), set to light mode |
31 | | - const systemDark = window.matchMedia ? window.matchMedia("(prefers-color-scheme: dark)").matches : false |
32 | | - return mode === "system" ? systemDark : mode === "dark" |
| 41 | + useEffect(() => { |
| 42 | + const item = localStorage.getItem("darkModeSelect") |
| 43 | + let storedSelect: ModeSelection | undefined |
| 44 | + if (item !== null) { |
| 45 | + if (item && modeSelections.includes(item)) { |
| 46 | + storedSelect = item |
| 47 | + } else { |
| 48 | + storedSelect = "system" |
| 49 | + } |
| 50 | + } else { |
| 51 | + storedSelect = "system" |
| 52 | + } |
| 53 | + setModeSelection(storedSelect) |
| 54 | + }, []) |
| 55 | + |
| 56 | + const isDark = modeSelection === undefined || modeSelection === "system" ? isSystemDark : modeSelection === "dark" |
| 57 | + return [isDark, modeSelection, setModeSelection] |
33 | 58 | } |
34 | 59 |
|
35 | | -export function DarkModeToggle({ mode, onModeChange, className, ...rest }: MyComponentProps) { |
36 | | - useEffect(() => { |
37 | | - localStorage.setItem("darkModeSelect", mode) |
38 | | - }, [mode]) |
| 60 | +interface MyComponentProps extends ButtonProps { |
| 61 | + modeSelection: ModeSelection | undefined |
| 62 | + setModeSelection: React.Dispatch<React.SetStateAction<ModeSelection | undefined>> |
| 63 | +} |
39 | 64 |
|
40 | | - return ( |
41 | | - <Tooltip content={`Toggle dark mode (currently ${mode})`}> |
| 65 | +export function DarkModeToggle({ modeSelection, setModeSelection, className, ...rest }: MyComponentProps) { |
| 66 | + return modeSelection ? ( |
| 67 | + <Tooltip content={`Toggle dark mode (currently ${modeSelection} mode)`}> |
42 | 68 | <Button |
43 | 69 | isIconOnly |
44 | 70 | className={`mr-2 rounded-full ${tst} bg-background hover:bg-default-100` + " " + className} |
45 | 71 | aria-label="Toggle dark mode" |
46 | 72 | onPress={() => { |
47 | | - const curModeIdx = icons.findIndex(({ name }) => name === mode) |
48 | | - onModeChange(icons[(curModeIdx + 1) % icons.length].name) |
| 73 | + const newSelected = modeSelections[(modeSelections.indexOf(modeSelection) + 1) % modeSelections.length] |
| 74 | + setModeSelection(newSelected) |
49 | 75 | }} |
50 | 76 | {...rest} |
51 | 77 | > |
52 | | - {icons.find(({ name }) => name === mode)!.icon} |
| 78 | + {icons[modeSelection]} |
53 | 79 | </Button> |
54 | 80 | </Tooltip> |
55 | | - ) |
| 81 | + ) : null |
56 | 82 | } |
0 commit comments