@takazudo/zudo-doc 5.26.4 → 5.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/compiled.css +18 -0
- package/dist/extract-headings/index.d.ts +2 -0
- package/dist/extract-headings/index.js +40 -1
- package/dist/header/header.js +47 -19
- package/dist/header-with-defaults/index.js +5 -1
- package/dist/i18n-defaults/index.js +10 -0
- package/dist/plugins/routes.js +12 -2
- package/dist/safelist.css +1 -1
- package/dist/search-widget/index.js +1 -1
- package/dist/sidebar-toggle-island/index.d.ts +4 -1
- package/dist/sidebar-toggle-island/index.js +4 -0
- package/dist/sidebar-tree-island/index.d.ts +4 -1
- package/dist/sidebar-tree-island/index.js +5 -5
- package/dist/sidebar-with-defaults/index.js +3 -0
- package/dist/theme/color-scheme-provider.js +40 -10
- package/dist/theme/theme-toggle.d.ts +1 -1
- package/dist/theme-toggle/color-scheme-sync.d.ts +19 -39
- package/dist/theme-toggle/color-scheme-sync.js +81 -6
- package/dist/theme-toggle/index.d.ts +10 -1
- package/dist/theme-toggle/index.js +217 -58
- package/dist/theme-toggle/labels.d.ts +3 -0
- package/dist/theme-toggle/labels.js +12 -0
- package/eject/header/header.tsx +56 -19
- package/eject/sidebar-toggle-island/index.tsx +7 -0
- package/eject/sidebar-tree-island/index.tsx +8 -6
- package/eject/theme-toggle/color-scheme-sync.ts +108 -63
- package/eject/theme-toggle/index.tsx +204 -89
- package/eject/theme-toggle/labels.ts +15 -0
- package/package.json +8 -8
|
@@ -1,20 +1,50 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
|
|
2
2
|
import { AFTER_NAVIGATE_EVENT } from "../transitions/page-events.js";
|
|
3
|
+
import {
|
|
4
|
+
COLOR_SCHEME_CHANGED_EVENT,
|
|
5
|
+
COLOR_SCHEME_RUNTIME_GLOBAL,
|
|
6
|
+
COLOR_SCHEME_STORAGE_KEY,
|
|
7
|
+
normalizeThemePreference,
|
|
8
|
+
resolveColorScheme,
|
|
9
|
+
resolveThemePreference
|
|
10
|
+
} from "../theme-toggle/color-scheme-sync.js";
|
|
3
11
|
function buildColorModeBootstrap(defaultMode, respectPrefersColorScheme) {
|
|
4
12
|
const dm = JSON.stringify(defaultMode);
|
|
5
13
|
const rp = JSON.stringify(Boolean(respectPrefersColorScheme));
|
|
6
14
|
const afterNav = JSON.stringify(AFTER_NAVIGATE_EVENT);
|
|
15
|
+
const globalName = JSON.stringify(COLOR_SCHEME_RUNTIME_GLOBAL);
|
|
16
|
+
const storageKey = JSON.stringify(COLOR_SCHEME_STORAGE_KEY);
|
|
17
|
+
const modeEvent = JSON.stringify(COLOR_SCHEME_CHANGED_EVENT);
|
|
7
18
|
return `(function(){
|
|
8
|
-
var defaultMode=${dm};
|
|
9
|
-
var
|
|
10
|
-
var
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
var
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
var defaultMode=${dm},respectPrefersColorScheme=${rp};
|
|
20
|
+
var STORAGE_KEY=${storageKey};
|
|
21
|
+
var normalizeThemePreference=${normalizeThemePreference.toString()};
|
|
22
|
+
var resolveThemePreference=${resolveThemePreference.toString()};
|
|
23
|
+
var resolveColorScheme=${resolveColorScheme.toString()};
|
|
24
|
+
var existing=window[${globalName}];
|
|
25
|
+
var state=existing||{choice:null,defaultMode:defaultMode,respectPrefersColorScheme:respectPrefersColorScheme};
|
|
26
|
+
if(existing&&existing.cleanup)existing.cleanup();
|
|
27
|
+
if(!existing){try{state.choice=normalizeThemePreference(localStorage.getItem(STORAGE_KEY));}catch(e){}}
|
|
28
|
+
state.defaultMode=defaultMode;
|
|
29
|
+
state.respectPrefersColorScheme=respectPrefersColorScheme;
|
|
30
|
+
window[${globalName}]=state;
|
|
31
|
+
var media=window.matchMedia("(prefers-color-scheme: dark)");
|
|
32
|
+
function applyTheme(){
|
|
33
|
+
var preference=resolveThemePreference(state.choice,state.defaultMode,state.respectPrefersColorScheme);
|
|
34
|
+
var mode=resolveColorScheme(preference,media.matches);
|
|
35
|
+
var root=document.documentElement;
|
|
36
|
+
var changed=(state.lastMode||root.getAttribute("data-theme"))!==mode;
|
|
37
|
+
root.setAttribute("data-theme",mode);
|
|
38
|
+
root.style.colorScheme=mode;
|
|
39
|
+
state.lastMode=mode;
|
|
40
|
+
if(changed)window.dispatchEvent(new CustomEvent(${modeEvent}));
|
|
41
|
+
}
|
|
42
|
+
function onNavigate(){applyTheme();}
|
|
43
|
+
function onMediaChange(){applyTheme();}
|
|
44
|
+
applyTheme();
|
|
45
|
+
document.addEventListener(${afterNav},onNavigate);
|
|
46
|
+
media.addEventListener("change",onMediaChange);
|
|
47
|
+
state.cleanup=function(){document.removeEventListener(${afterNav},onNavigate);media.removeEventListener("change",onMediaChange);};
|
|
18
48
|
})();`;
|
|
19
49
|
}
|
|
20
50
|
function ColorSchemeProvider({
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { VNode } from "preact";
|
|
4
4
|
import { type ThemeToggleProps } from "../theme-toggle/index.js";
|
|
5
5
|
/**
|
|
6
|
-
* Default-export
|
|
6
|
+
* Default-export appearance menu for theme preference. Wraps the bare
|
|
7
7
|
* `<ThemeToggle>` in `<Island when="load">` so the SSG renderer emits a
|
|
8
8
|
* `data-zfb-island="ThemeToggle"` marker the hydration runtime can
|
|
9
9
|
* find at boot time. Call sites that already sit inside an island (or
|
|
@@ -1,44 +1,24 @@
|
|
|
1
1
|
export type ColorSchemeMode = "light" | "dark";
|
|
2
|
+
export type ThemePreference = ColorSchemeMode | "system";
|
|
2
3
|
export declare const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export declare const THEME_PREFERENCE_CHANGED_EVENT = "theme-preference-changed";
|
|
5
|
+
export declare const COLOR_SCHEME_RUNTIME_GLOBAL = "__zudoDocColorScheme";
|
|
6
|
+
export declare const COLOR_SCHEME_STORAGE_KEY = "zudo-doc-theme";
|
|
7
|
+
export interface ColorSchemeRuntime {
|
|
8
|
+
choice: ThemePreference | null;
|
|
9
|
+
defaultMode: ColorSchemeMode;
|
|
10
|
+
respectPrefersColorScheme: boolean;
|
|
11
|
+
lastMode?: ColorSchemeMode;
|
|
12
|
+
cleanup?: () => void;
|
|
13
|
+
}
|
|
14
|
+
export declare function normalizeThemePreference(value: unknown): ThemePreference | null;
|
|
15
|
+
export declare function resolveThemePreference(choice: ThemePreference | null, defaultMode: ColorSchemeMode, respectPrefersColorScheme: boolean): ThemePreference;
|
|
16
|
+
export declare function resolveColorScheme(preference: ThemePreference, systemIsDark: boolean): ColorSchemeMode;
|
|
17
|
+
export declare function readThemePreference(defaultMode?: ColorSchemeMode, respectPrefersColorScheme?: boolean): ThemePreference;
|
|
8
18
|
export declare function readColorSchemeFromDom(defaultMode: ColorSchemeMode): ColorSchemeMode;
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* ThemeToggle instances and the zdtp design-token panel) via the
|
|
13
|
-
* `color-scheme-changed` window event.
|
|
14
|
-
*
|
|
15
|
-
* Tweak-state reconciliation is intentionally NOT done here (#2037). The zdtp
|
|
16
|
-
* panel owns its own storage lifecycle and current persisted-state contract;
|
|
17
|
-
* its own `color-scheme-changed` listener clears applied inline
|
|
18
|
-
* styles and re-seeds the color slice from the newly active scheme. An
|
|
19
|
-
* earlier version of this function deleted `zudo-doc-tweak-state` + `-v2` on
|
|
20
|
-
* every toggle, which (a) targeted stale keys after zdtp moved to v3 — so it
|
|
21
|
-
* no longer did anything — and (b) when it did fire, wiped the whole envelope
|
|
22
|
-
* including scheme-independent spacing/typography/size tweaks, contradicting
|
|
23
|
-
* the documented carry-over guarantee. So the host no longer touches zdtp's
|
|
24
|
-
* private storage keys.
|
|
25
|
-
*
|
|
26
|
-
* The design-token-panel bootstrap ALSO listens for this event and, on toggle,
|
|
27
|
-
* destroys + reconfigures the panel with the new mode's mode-scoped semantic
|
|
28
|
-
* DEFAULTS (see `design-token-panel-bootstrap.ts` + the host's
|
|
29
|
-
* `buildDesignTokenPanelConfig`, #2610). That keeps the panel's per-mode
|
|
30
|
-
* defaults faithful. Saved Color overrides are scheme- and mode-scoped:
|
|
31
|
-
* the package-default builder declares separate Default Light / Default Dark
|
|
32
|
-
* identities in panelSettings.colorMode, so an edit made in one mode does not
|
|
33
|
-
* replace the other mode's default or saved mapping. Returning to a mode
|
|
34
|
-
* restores that identity's saved choice. Palette, Spacing, Font, and Size
|
|
35
|
-
* overrides remain shared across light/dark within the active theme pack.
|
|
36
|
-
* The browser contract lives in e2e/theme-panel-persistence.spec.ts (#3980).
|
|
37
|
-
* See zudo-doc#2037 / #2610.
|
|
38
|
-
*/
|
|
19
|
+
/** Select a preference. Storage is best effort; the live choice survives errors. */
|
|
20
|
+
export declare function applyThemePreference(next: ThemePreference): void;
|
|
21
|
+
/** Existing light/dark callers explicitly select the corresponding preference. */
|
|
39
22
|
export declare function applyColorScheme(next: ColorSchemeMode): void;
|
|
40
|
-
/**
|
|
41
|
-
* Subscribe to color-scheme changes. Returns an unsubscribe function
|
|
42
|
-
* (suitable as a `useEffect` cleanup).
|
|
43
|
-
*/
|
|
44
23
|
export declare function subscribeColorSchemeChanged(listener: () => void): () => void;
|
|
24
|
+
export declare function subscribeThemePreferenceChanged(listener: () => void): () => void;
|
|
@@ -1,22 +1,97 @@
|
|
|
1
1
|
const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
|
|
2
|
-
const
|
|
2
|
+
const THEME_PREFERENCE_CHANGED_EVENT = "theme-preference-changed";
|
|
3
|
+
const COLOR_SCHEME_RUNTIME_GLOBAL = "__zudoDocColorScheme";
|
|
4
|
+
const COLOR_SCHEME_STORAGE_KEY = "zudo-doc-theme";
|
|
5
|
+
function normalizeThemePreference(value) {
|
|
6
|
+
return value === "light" || value === "dark" || value === "system" ? value : null;
|
|
7
|
+
}
|
|
8
|
+
function resolveThemePreference(choice, defaultMode, respectPrefersColorScheme) {
|
|
9
|
+
return choice ?? (respectPrefersColorScheme ? "system" : defaultMode);
|
|
10
|
+
}
|
|
11
|
+
function resolveColorScheme(preference, systemIsDark) {
|
|
12
|
+
return preference === "system" ? systemIsDark ? "dark" : "light" : preference;
|
|
13
|
+
}
|
|
14
|
+
function runtime() {
|
|
15
|
+
return window[COLOR_SCHEME_RUNTIME_GLOBAL] ?? null;
|
|
16
|
+
}
|
|
17
|
+
function readStorage() {
|
|
18
|
+
try {
|
|
19
|
+
return normalizeThemePreference(localStorage.getItem(COLOR_SCHEME_STORAGE_KEY));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function getRuntime() {
|
|
25
|
+
const existing = runtime();
|
|
26
|
+
if (existing) return existing;
|
|
27
|
+
const initial = {
|
|
28
|
+
choice: readStorage(),
|
|
29
|
+
defaultMode: "dark",
|
|
30
|
+
respectPrefersColorScheme: true
|
|
31
|
+
};
|
|
32
|
+
window[COLOR_SCHEME_RUNTIME_GLOBAL] = initial;
|
|
33
|
+
return initial;
|
|
34
|
+
}
|
|
35
|
+
function readThemePreference(defaultMode = "dark", respectPrefersColorScheme = true) {
|
|
36
|
+
const state = runtime();
|
|
37
|
+
return resolveThemePreference(
|
|
38
|
+
state ? state.choice : readStorage(),
|
|
39
|
+
state?.defaultMode ?? defaultMode,
|
|
40
|
+
state?.respectPrefersColorScheme ?? respectPrefersColorScheme
|
|
41
|
+
);
|
|
42
|
+
}
|
|
3
43
|
function readColorSchemeFromDom(defaultMode) {
|
|
4
44
|
const actual = document.documentElement.getAttribute("data-theme");
|
|
5
45
|
return actual === "light" || actual === "dark" ? actual : defaultMode;
|
|
6
46
|
}
|
|
47
|
+
function applyThemePreference(next) {
|
|
48
|
+
const state = getRuntime();
|
|
49
|
+
const previousPreference = resolveThemePreference(
|
|
50
|
+
state.choice,
|
|
51
|
+
state.defaultMode,
|
|
52
|
+
state.respectPrefersColorScheme
|
|
53
|
+
);
|
|
54
|
+
const previousMode = state.lastMode ?? document.documentElement.getAttribute("data-theme");
|
|
55
|
+
state.choice = next;
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, next);
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
const systemIsDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
61
|
+
const mode = resolveColorScheme(next, systemIsDark);
|
|
62
|
+
document.documentElement.setAttribute("data-theme", mode);
|
|
63
|
+
document.documentElement.style.colorScheme = mode;
|
|
64
|
+
state.lastMode = mode;
|
|
65
|
+
if (previousMode !== mode) {
|
|
66
|
+
window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
|
|
67
|
+
}
|
|
68
|
+
if (previousPreference !== next) {
|
|
69
|
+
window.dispatchEvent(new CustomEvent(THEME_PREFERENCE_CHANGED_EVENT));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
7
72
|
function applyColorScheme(next) {
|
|
8
|
-
|
|
9
|
-
document.documentElement.style.colorScheme = next;
|
|
10
|
-
localStorage.setItem(STORAGE_KEY, next);
|
|
11
|
-
window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
|
|
73
|
+
applyThemePreference(next);
|
|
12
74
|
}
|
|
13
75
|
function subscribeColorSchemeChanged(listener) {
|
|
14
76
|
window.addEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
|
|
15
77
|
return () => window.removeEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
|
|
16
78
|
}
|
|
79
|
+
function subscribeThemePreferenceChanged(listener) {
|
|
80
|
+
window.addEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
|
|
81
|
+
return () => window.removeEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
|
|
82
|
+
}
|
|
17
83
|
export {
|
|
18
84
|
COLOR_SCHEME_CHANGED_EVENT,
|
|
85
|
+
COLOR_SCHEME_RUNTIME_GLOBAL,
|
|
86
|
+
COLOR_SCHEME_STORAGE_KEY,
|
|
87
|
+
THEME_PREFERENCE_CHANGED_EVENT,
|
|
19
88
|
applyColorScheme,
|
|
89
|
+
applyThemePreference,
|
|
90
|
+
normalizeThemePreference,
|
|
20
91
|
readColorSchemeFromDom,
|
|
21
|
-
|
|
92
|
+
readThemePreference,
|
|
93
|
+
resolveColorScheme,
|
|
94
|
+
resolveThemePreference,
|
|
95
|
+
subscribeColorSchemeChanged,
|
|
96
|
+
subscribeThemePreferenceChanged
|
|
22
97
|
};
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { type ColorSchemeMode } from "./color-scheme-sync.js";
|
|
2
|
+
export interface ThemeToggleLabels {
|
|
3
|
+
appearance: string;
|
|
4
|
+
light: string;
|
|
5
|
+
dark: string;
|
|
6
|
+
system: string;
|
|
7
|
+
systemHelper: string;
|
|
8
|
+
}
|
|
2
9
|
export interface ThemeToggleProps {
|
|
3
10
|
defaultMode?: ColorSchemeMode;
|
|
11
|
+
respectPrefersColorScheme?: boolean;
|
|
12
|
+
labels?: ThemeToggleLabels;
|
|
4
13
|
/** Keep activation pending until the first successful mount. @default true */
|
|
5
14
|
pendingUntilHydrated?: boolean;
|
|
6
15
|
}
|
|
7
|
-
export declare function ThemeToggle({ defaultMode, pendingUntilHydrated, }: ThemeToggleProps): import("preact").JSX.Element;
|
|
16
|
+
export declare function ThemeToggle({ defaultMode, respectPrefersColorScheme, labels, pendingUntilHydrated, }: ThemeToggleProps): import("preact").JSX.Element;
|
|
8
17
|
export declare namespace ThemeToggle {
|
|
9
18
|
var displayName: string;
|
|
10
19
|
}
|
|
@@ -1,41 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "preact/jsx-runtime";
|
|
3
|
-
import { useState, useEffect } from "preact/hooks";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
|
|
3
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
4
|
+
import { createPortal } from "preact/compat";
|
|
4
5
|
import { useHydrationPending } from "../hydration-pending.js";
|
|
6
|
+
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
5
7
|
import {
|
|
6
|
-
|
|
8
|
+
applyThemePreference,
|
|
7
9
|
readColorSchemeFromDom,
|
|
8
|
-
|
|
10
|
+
readThemePreference,
|
|
11
|
+
subscribeColorSchemeChanged,
|
|
12
|
+
subscribeThemePreferenceChanged
|
|
9
13
|
} from "./color-scheme-sync.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
{
|
|
14
|
-
"aria-hidden": "true",
|
|
15
|
-
xmlns: "http://www.w3.org/2000/svg",
|
|
16
|
-
width: "20",
|
|
17
|
-
height: "20",
|
|
18
|
-
viewBox: "0 0 24 24",
|
|
19
|
-
fill: "none",
|
|
20
|
-
stroke: "currentColor",
|
|
21
|
-
strokeWidth: "2",
|
|
22
|
-
strokeLinecap: "round",
|
|
23
|
-
strokeLinejoin: "round",
|
|
24
|
-
children: [
|
|
25
|
-
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "5" }),
|
|
26
|
-
/* @__PURE__ */ jsx("line", { x1: "12", y1: "1", x2: "12", y2: "3" }),
|
|
27
|
-
/* @__PURE__ */ jsx("line", { x1: "12", y1: "21", x2: "12", y2: "23" }),
|
|
28
|
-
/* @__PURE__ */ jsx("line", { x1: "4.22", y1: "4.22", x2: "5.64", y2: "5.64" }),
|
|
29
|
-
/* @__PURE__ */ jsx("line", { x1: "18.36", y1: "18.36", x2: "19.78", y2: "19.78" }),
|
|
30
|
-
/* @__PURE__ */ jsx("line", { x1: "1", y1: "12", x2: "3", y2: "12" }),
|
|
31
|
-
/* @__PURE__ */ jsx("line", { x1: "21", y1: "12", x2: "23", y2: "12" }),
|
|
32
|
-
/* @__PURE__ */ jsx("line", { x1: "4.22", y1: "19.78", x2: "5.64", y2: "18.36" }),
|
|
33
|
-
/* @__PURE__ */ jsx("line", { x1: "18.36", y1: "5.64", x2: "19.78", y2: "4.22" })
|
|
34
|
-
]
|
|
35
|
-
}
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
function MoonIcon() {
|
|
14
|
+
const preferences = ["light", "dark", "system"];
|
|
15
|
+
let menuSequence = 0;
|
|
16
|
+
function PreferenceIcon({ preference }) {
|
|
39
17
|
return /* @__PURE__ */ jsx(
|
|
40
18
|
"svg",
|
|
41
19
|
{
|
|
@@ -46,42 +24,223 @@ function MoonIcon() {
|
|
|
46
24
|
viewBox: "0 0 24 24",
|
|
47
25
|
fill: "none",
|
|
48
26
|
stroke: "currentColor",
|
|
49
|
-
strokeWidth: "2",
|
|
50
|
-
strokeLinecap: "
|
|
51
|
-
strokeLinejoin: "
|
|
52
|
-
children: /* @__PURE__ */
|
|
27
|
+
strokeWidth: "1.2",
|
|
28
|
+
strokeLinecap: "square",
|
|
29
|
+
strokeLinejoin: "miter",
|
|
30
|
+
children: preference === "light" ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
31
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3.6" }),
|
|
32
|
+
/* @__PURE__ */ jsx("path", { d: "M12 2.5V6M12 18V21.5M2.5 12H6M18 12H21.5M5.3 5.3L7.8 7.8M16.2 16.2L18.7 18.7M5.3 18.7L7.8 16.2M16.2 7.8L18.7 5.3" })
|
|
33
|
+
] }) : preference === "dark" ? /* @__PURE__ */ jsx("path", { d: "M10.2 2.9C5.7 3.9 2.6 7.9 3 12.6C3.4 17.7 7.9 21.5 13 21.1C16.9 20.7 20.1 17.9 21 14.1C18.6 15.7 15.6 15.8 13.2 14.3C9.3 12 8 6.8 10.2 2.9Z" }) : /* @__PURE__ */ jsx("path", { d: "M2.5 3.5H21.5V16.5H2.5ZM12 16.5V20.5M7.5 20.5H16.5" })
|
|
53
34
|
}
|
|
54
35
|
);
|
|
55
36
|
}
|
|
37
|
+
const englishLabels = {
|
|
38
|
+
appearance: "Appearance",
|
|
39
|
+
light: "Light",
|
|
40
|
+
dark: "Dark",
|
|
41
|
+
system: "System",
|
|
42
|
+
systemHelper: "Follows device \xB7 currently {mode}"
|
|
43
|
+
};
|
|
56
44
|
function ThemeToggle({
|
|
57
45
|
defaultMode = "dark",
|
|
46
|
+
respectPrefersColorScheme = true,
|
|
47
|
+
labels = englishLabels,
|
|
58
48
|
pendingUntilHydrated = true
|
|
59
49
|
}) {
|
|
60
50
|
const pending = useHydrationPending(pendingUntilHydrated);
|
|
61
|
-
const
|
|
51
|
+
const menuId = useRef("");
|
|
52
|
+
const ensureMenuId = () => {
|
|
53
|
+
if (!menuId.current) menuId.current = `zd-appearance-${++menuSequence}`;
|
|
54
|
+
};
|
|
55
|
+
const rootRef = useRef(null);
|
|
56
|
+
const menuRef = useRef(null);
|
|
57
|
+
const triggerRef = useRef(null);
|
|
58
|
+
const itemRefs = useRef([]);
|
|
59
|
+
const restoreFocusRef = useRef(false);
|
|
60
|
+
const [preference, setPreference] = useState(
|
|
61
|
+
respectPrefersColorScheme ? "system" : defaultMode
|
|
62
|
+
);
|
|
63
|
+
const [resolved, setResolved] = useState(defaultMode);
|
|
64
|
+
const [open, setOpen] = useState(false);
|
|
65
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
66
|
+
const [placement, setPlacement] = useState(null);
|
|
62
67
|
useEffect(() => {
|
|
63
|
-
const sync = () =>
|
|
68
|
+
const sync = () => {
|
|
69
|
+
setPreference(readThemePreference(defaultMode, respectPrefersColorScheme));
|
|
70
|
+
setResolved(readColorSchemeFromDom(defaultMode));
|
|
71
|
+
};
|
|
64
72
|
sync();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
const unsubscribePreference = subscribeThemePreferenceChanged(() => {
|
|
74
|
+
sync();
|
|
75
|
+
setOpen(false);
|
|
76
|
+
});
|
|
77
|
+
const unsubscribeScheme = subscribeColorSchemeChanged(sync);
|
|
78
|
+
return () => {
|
|
79
|
+
unsubscribePreference();
|
|
80
|
+
unsubscribeScheme();
|
|
81
|
+
};
|
|
82
|
+
}, [defaultMode, respectPrefersColorScheme]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!open) return;
|
|
85
|
+
const menu = menuRef.current;
|
|
86
|
+
menu?.showPopover?.();
|
|
87
|
+
const position = () => {
|
|
88
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
89
|
+
if (!rect) return;
|
|
90
|
+
const gap = 8;
|
|
91
|
+
const width = Math.min(260, window.innerWidth - gap * 2);
|
|
92
|
+
const desiredHeight = menu?.scrollHeight ?? 280;
|
|
93
|
+
const roomBelow = window.innerHeight - rect.bottom - gap * 2;
|
|
94
|
+
const roomAbove = rect.top - gap * 2;
|
|
95
|
+
const above = roomBelow < desiredHeight && roomAbove > roomBelow;
|
|
96
|
+
const maxHeight = Math.max(80, Math.min(desiredHeight, above ? roomAbove : roomBelow));
|
|
97
|
+
setPlacement({
|
|
98
|
+
left: Math.max(gap, Math.min(rect.right - width, window.innerWidth - width - gap)),
|
|
99
|
+
top: above ? Math.max(gap, rect.top - gap - maxHeight) : rect.bottom + gap,
|
|
100
|
+
width,
|
|
101
|
+
maxHeight
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const onPointerDown = (event) => {
|
|
105
|
+
if (!rootRef.current?.contains(event.target) && !menuRef.current?.contains(event.target)) setOpen(false);
|
|
106
|
+
};
|
|
107
|
+
const onNavigate = () => setOpen(false);
|
|
108
|
+
position();
|
|
109
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
110
|
+
window.addEventListener("resize", position);
|
|
111
|
+
window.addEventListener("scroll", position, true);
|
|
112
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
|
|
113
|
+
return () => {
|
|
114
|
+
if (menu?.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
|
|
115
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
116
|
+
window.removeEventListener("resize", position);
|
|
117
|
+
window.removeEventListener("scroll", position, true);
|
|
118
|
+
document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
|
|
119
|
+
};
|
|
120
|
+
}, [open]);
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (open && placement) itemRefs.current[activeIndex]?.focus();
|
|
123
|
+
}, [open, placement]);
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (open || !restoreFocusRef.current) return;
|
|
126
|
+
restoreFocusRef.current = false;
|
|
127
|
+
requestAnimationFrame(() => triggerRef.current?.focus());
|
|
128
|
+
}, [open]);
|
|
129
|
+
const close = (restoreFocus = false) => {
|
|
130
|
+
restoreFocusRef.current = restoreFocus;
|
|
131
|
+
setOpen(false);
|
|
132
|
+
};
|
|
133
|
+
const select = (next) => {
|
|
134
|
+
close(true);
|
|
135
|
+
applyThemePreference(next);
|
|
136
|
+
setPreference(next);
|
|
137
|
+
setResolved(readColorSchemeFromDom(defaultMode));
|
|
138
|
+
};
|
|
139
|
+
const move = (index) => {
|
|
140
|
+
const next = (index + preferences.length) % preferences.length;
|
|
141
|
+
setActiveIndex(next);
|
|
142
|
+
itemRefs.current[next]?.focus();
|
|
143
|
+
};
|
|
144
|
+
const onMenuKeyDown = (event) => {
|
|
145
|
+
if (event.key === "Escape") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
close(true);
|
|
148
|
+
} else if (event.key === "Tab") {
|
|
149
|
+
window.setTimeout(() => close(), 0);
|
|
150
|
+
} else if (event.key === "ArrowDown") {
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
move(activeIndex + 1);
|
|
153
|
+
} else if (event.key === "ArrowUp") {
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
move(activeIndex - 1);
|
|
156
|
+
} else if (event.key === "Home") {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
move(0);
|
|
159
|
+
} else if (event.key === "End") {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
move(2);
|
|
83
162
|
}
|
|
84
|
-
|
|
163
|
+
};
|
|
164
|
+
return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: "relative inline-flex", "data-zd-theme-menu": "", children: [
|
|
165
|
+
/* @__PURE__ */ jsx(
|
|
166
|
+
"button",
|
|
167
|
+
{
|
|
168
|
+
ref: triggerRef,
|
|
169
|
+
type: "button",
|
|
170
|
+
"aria-haspopup": "menu",
|
|
171
|
+
"aria-expanded": open,
|
|
172
|
+
"aria-controls": open ? menuId.current : void 0,
|
|
173
|
+
"aria-label": `${labels.appearance}: ${labels[preference]}`,
|
|
174
|
+
"aria-disabled": pending ? "true" : void 0,
|
|
175
|
+
"data-zd-pending": pending ? "" : void 0,
|
|
176
|
+
onClick: () => {
|
|
177
|
+
if (pending) return;
|
|
178
|
+
if (open) close(true);
|
|
179
|
+
else {
|
|
180
|
+
setPlacement(null);
|
|
181
|
+
ensureMenuId();
|
|
182
|
+
setActiveIndex(preferences.indexOf(preference));
|
|
183
|
+
setOpen(true);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
onKeyDown: (event) => {
|
|
187
|
+
if (pending) {
|
|
188
|
+
if (event.key === "Enter" || event.key === " ") event.preventDefault();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
setPlacement(null);
|
|
194
|
+
ensureMenuId();
|
|
195
|
+
setActiveIndex(event.key === "ArrowDown" ? 0 : 2);
|
|
196
|
+
setOpen(true);
|
|
197
|
+
} else if (event.key === "Escape" && open) {
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
close(true);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
className: "inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center text-muted hover:text-fg focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
|
|
203
|
+
children: /* @__PURE__ */ jsx(PreferenceIcon, { preference })
|
|
204
|
+
}
|
|
205
|
+
),
|
|
206
|
+
open && createPortal(/* @__PURE__ */ jsxs(
|
|
207
|
+
"div",
|
|
208
|
+
{
|
|
209
|
+
ref: menuRef,
|
|
210
|
+
id: menuId.current,
|
|
211
|
+
role: "menu",
|
|
212
|
+
"aria-label": labels.appearance,
|
|
213
|
+
popover: typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype ? "manual" : void 0,
|
|
214
|
+
onKeyDown: onMenuKeyDown,
|
|
215
|
+
className: "fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg",
|
|
216
|
+
style: placement ? { left: placement.left, top: placement.top, right: "auto", bottom: "auto", margin: 0, width: placement.width, maxHeight: placement.maxHeight } : { visibility: "hidden", left: 0, top: 0, right: "auto", bottom: "auto", margin: 0, width: Math.min(260, window.innerWidth - 16) },
|
|
217
|
+
children: [
|
|
218
|
+
/* @__PURE__ */ jsx("div", { className: "px-hsp-sm py-vsp-xs text-small font-semibold", "aria-hidden": "true", children: labels.appearance }),
|
|
219
|
+
preferences.map((option, index) => /* @__PURE__ */ jsxs(
|
|
220
|
+
"button",
|
|
221
|
+
{
|
|
222
|
+
ref: (node) => {
|
|
223
|
+
itemRefs.current[index] = node;
|
|
224
|
+
},
|
|
225
|
+
type: "button",
|
|
226
|
+
role: "menuitemradio",
|
|
227
|
+
"aria-checked": preference === option,
|
|
228
|
+
onFocus: () => setActiveIndex(index),
|
|
229
|
+
onClick: () => select(option),
|
|
230
|
+
className: `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference === option ? "bg-accent/10" : ""} hover:bg-accent/10 focus-visible:bg-accent/10 focus-visible:outline-2 focus-visible:outline-accent`,
|
|
231
|
+
children: [
|
|
232
|
+
/* @__PURE__ */ jsx(PreferenceIcon, { preference: option }),
|
|
233
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1", children: labels[option] }),
|
|
234
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "text-accent", children: preference === option ? "\u2713" : "" })
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
option
|
|
238
|
+
)),
|
|
239
|
+
/* @__PURE__ */ jsx("div", { className: "px-hsp-sm py-vsp-xs text-small text-muted", children: labels.systemHelper.replace("{mode}", labels[resolved]) })
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
), document.body)
|
|
243
|
+
] });
|
|
85
244
|
}
|
|
86
245
|
ThemeToggle.displayName = "ThemeToggle";
|
|
87
246
|
export {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
function themeToggleLabels(t, locale) {
|
|
2
|
+
return {
|
|
3
|
+
appearance: t("appearance.title", locale),
|
|
4
|
+
light: t("appearance.light", locale),
|
|
5
|
+
dark: t("appearance.dark", locale),
|
|
6
|
+
system: t("appearance.system", locale),
|
|
7
|
+
systemHelper: t("appearance.systemHelper", locale)
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
themeToggleLabels
|
|
12
|
+
};
|