@takazudo/zudo-doc 5.26.5 → 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 +16 -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/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
|
@@ -12,7 +12,7 @@ import { INDENT, BASE_PAD, connectorLeft, ConnectorLines, CategoryLinkIcon } fro
|
|
|
12
12
|
import { ChevronRight, ChevronLeft, Search } from "../icons/index.js";
|
|
13
13
|
// BARE ThemeToggle — renders inside the SidebarToggle island, so it must
|
|
14
14
|
// NOT bring its own island wrapper.
|
|
15
|
-
import { ThemeToggle } from "../theme-toggle/index.js";
|
|
15
|
+
import { ThemeToggle, type ThemeToggleLabels } from "../theme-toggle/index.js";
|
|
16
16
|
import { smartBreakToHtml } from "../smart-break/index.js";
|
|
17
17
|
// After zudolab/zudo-doc#1335 the host components also pull lifecycle event
|
|
18
18
|
// names from the v2 transitions module rather than hard-coding literals.
|
|
@@ -184,6 +184,8 @@ export interface SidebarTreeProps {
|
|
|
184
184
|
locale?: string;
|
|
185
185
|
localeLinks?: SidebarLocaleLink[];
|
|
186
186
|
themeDefaultMode?: "light" | "dark";
|
|
187
|
+
themeLabels?: ThemeToggleLabels;
|
|
188
|
+
themeRespectSystem?: boolean;
|
|
187
189
|
/**
|
|
188
190
|
* Per-role date patterns already resolved for this page's locale, serialized
|
|
189
191
|
* into the island's `data-props` by the SSR wrappers (`sidebar-with-defaults`
|
|
@@ -197,13 +199,13 @@ export interface SidebarTreeProps {
|
|
|
197
199
|
dateFormats?: ResolvedDateFormats;
|
|
198
200
|
}
|
|
199
201
|
|
|
200
|
-
function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[]; themeDefaultMode?: "light" | "dark" }) {
|
|
202
|
+
function SidebarFooter({ links, themeDefaultMode, themeLabels, themeRespectSystem }: { links?: SidebarLocaleLink[]; themeDefaultMode?: "light" | "dark"; themeLabels?: ThemeToggleLabels; themeRespectSystem?: boolean }) {
|
|
201
203
|
if (!links && !themeDefaultMode) return null;
|
|
202
204
|
return (
|
|
203
205
|
// pb-[50vh] provides scroll room so the footer doesn't sit at the very bottom of the viewport
|
|
204
206
|
<div className="lg:hidden flex items-center gap-hsp-md border-t border-muted px-hsp-sm py-vsp-xs pb-[50vh] text-small">
|
|
205
207
|
{themeDefaultMode && (
|
|
206
|
-
<ThemeToggle defaultMode={themeDefaultMode} pendingUntilHydrated={true} />
|
|
208
|
+
<ThemeToggle defaultMode={themeDefaultMode} labels={themeLabels} respectPrefersColorScheme={themeRespectSystem} pendingUntilHydrated={true} />
|
|
207
209
|
)}
|
|
208
210
|
{links && links.map((link, i) => (
|
|
209
211
|
<span key={link.href} className="flex items-center gap-hsp-xs">
|
|
@@ -221,7 +223,7 @@ function SidebarFooter({ links, themeDefaultMode }: { links?: SidebarLocaleLink[
|
|
|
221
223
|
);
|
|
222
224
|
}
|
|
223
225
|
|
|
224
|
-
export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, backToMenuLabel, locale: localeProp, localeLinks, themeDefaultMode, dateFormats }: SidebarTreeProps) {
|
|
226
|
+
export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, backToMenuLabel, locale: localeProp, localeLinks, themeDefaultMode, themeLabels, themeRespectSystem, dateFormats }: SidebarTreeProps) {
|
|
225
227
|
const activeSlug = useActiveSlug(nodes, currentSlug, currentPath);
|
|
226
228
|
const [query, setQuery] = useState("");
|
|
227
229
|
const [showingRootMenu, setShowingRootMenu] = useState(false);
|
|
@@ -257,8 +259,8 @@ export function SidebarTree({ nodes, currentSlug, currentPath, rootMenuItems, ba
|
|
|
257
259
|
);
|
|
258
260
|
|
|
259
261
|
const footer = useMemo(
|
|
260
|
-
() => (localeLinks || themeDefaultMode) ? <SidebarFooter links={localeLinks} themeDefaultMode={themeDefaultMode} /> : null,
|
|
261
|
-
[localeLinks, themeDefaultMode],
|
|
262
|
+
() => (localeLinks || themeDefaultMode) ? <SidebarFooter links={localeLinks} themeDefaultMode={themeDefaultMode} themeLabels={themeLabels} themeRespectSystem={themeRespectSystem} /> : null,
|
|
263
|
+
[localeLinks, themeDefaultMode, themeLabels, themeRespectSystem],
|
|
262
264
|
);
|
|
263
265
|
|
|
264
266
|
const noteTrayRoot = nodes.length === 1 && nodes[0]?.shape === "note-tray" ? nodes[0] : undefined;
|
|
@@ -1,81 +1,126 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Two ThemeToggle instances can be mounted at once (header + mobile
|
|
4
|
-
// sidebar footer). Each instance holds its own `mode` state, so a
|
|
5
|
-
// toggle in one used to leave the other's icon stale — the instances
|
|
6
|
-
// only read the DOM at mount. These helpers centralise the write path
|
|
7
|
-
// (`applyColorScheme`) and give every instance a subscription point
|
|
8
|
-
// (`subscribeColorSchemeChanged`) keyed on the `color-scheme-changed`
|
|
9
|
-
// window event, which `applyColorScheme` dispatches after mutating the
|
|
10
|
-
// DOM. The same event is already consumed by the zdtp design-token
|
|
11
|
-
// panel, so the event name is a cross-package contract — do not rename.
|
|
12
|
-
//
|
|
13
|
-
// This module is intentionally NOT marked "use client": zfb's island
|
|
14
|
-
// scanner registers every exported binding of a "use client" file as an
|
|
15
|
-
// island, and these helpers are plain functions, not components. They
|
|
16
|
-
// run in the browser only (called from the ThemeToggle island and
|
|
17
|
-
// unit tests).
|
|
18
|
-
|
|
1
|
+
// Browser color-scheme state shared by the pre-paint bootstrap and client islands.
|
|
2
|
+
// The selected preference is distinct from the effective light/dark appearance.
|
|
19
3
|
export type ColorSchemeMode = "light" | "dark";
|
|
4
|
+
export type ThemePreference = ColorSchemeMode | "system";
|
|
20
5
|
|
|
21
6
|
export const COLOR_SCHEME_CHANGED_EVENT = "color-scheme-changed";
|
|
7
|
+
export const THEME_PREFERENCE_CHANGED_EVENT = "theme-preference-changed";
|
|
8
|
+
export const COLOR_SCHEME_RUNTIME_GLOBAL = "__zudoDocColorScheme";
|
|
9
|
+
export const COLOR_SCHEME_STORAGE_KEY = "zudo-doc-theme";
|
|
22
10
|
|
|
23
|
-
|
|
11
|
+
export interface ColorSchemeRuntime {
|
|
12
|
+
// null means no valid persisted or live explicit choice.
|
|
13
|
+
choice: ThemePreference | null;
|
|
14
|
+
defaultMode: ColorSchemeMode;
|
|
15
|
+
respectPrefersColorScheme: boolean;
|
|
16
|
+
lastMode?: ColorSchemeMode;
|
|
17
|
+
cleanup?: () => void;
|
|
18
|
+
}
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
// Keep these pure functions self-contained: the provider serializes their JS
|
|
21
|
+
// bodies into the inline pre-paint script, so both paths use identical rules.
|
|
22
|
+
export function normalizeThemePreference(value: unknown): ThemePreference | null {
|
|
23
|
+
return value === "light" || value === "dark" || value === "system"
|
|
24
|
+
? value
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveThemePreference(
|
|
29
|
+
choice: ThemePreference | null,
|
|
31
30
|
defaultMode: ColorSchemeMode,
|
|
31
|
+
respectPrefersColorScheme: boolean,
|
|
32
|
+
): ThemePreference {
|
|
33
|
+
return choice ?? (respectPrefersColorScheme ? "system" : defaultMode);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveColorScheme(
|
|
37
|
+
preference: ThemePreference,
|
|
38
|
+
systemIsDark: boolean,
|
|
32
39
|
): ColorSchemeMode {
|
|
40
|
+
return preference === "system" ? (systemIsDark ? "dark" : "light") : preference;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function runtime(): ColorSchemeRuntime | null {
|
|
44
|
+
return ((window as unknown as Record<string, unknown>)[COLOR_SCHEME_RUNTIME_GLOBAL] ??
|
|
45
|
+
null) as ColorSchemeRuntime | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readStorage(): ThemePreference | null {
|
|
49
|
+
try {
|
|
50
|
+
return normalizeThemePreference(localStorage.getItem(COLOR_SCHEME_STORAGE_KEY));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getRuntime(): ColorSchemeRuntime {
|
|
57
|
+
const existing = runtime();
|
|
58
|
+
if (existing) return existing;
|
|
59
|
+
const initial: ColorSchemeRuntime = {
|
|
60
|
+
choice: readStorage(),
|
|
61
|
+
defaultMode: "dark",
|
|
62
|
+
respectPrefersColorScheme: true,
|
|
63
|
+
};
|
|
64
|
+
(window as unknown as Record<string, unknown>)[COLOR_SCHEME_RUNTIME_GLOBAL] = initial;
|
|
65
|
+
return initial;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function readThemePreference(
|
|
69
|
+
defaultMode: ColorSchemeMode = "dark",
|
|
70
|
+
respectPrefersColorScheme = true,
|
|
71
|
+
): ThemePreference {
|
|
72
|
+
const state = runtime();
|
|
73
|
+
return resolveThemePreference(
|
|
74
|
+
state ? state.choice : readStorage(),
|
|
75
|
+
state?.defaultMode ?? defaultMode,
|
|
76
|
+
state?.respectPrefersColorScheme ?? respectPrefersColorScheme,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readColorSchemeFromDom(defaultMode: ColorSchemeMode): ColorSchemeMode {
|
|
33
81
|
const actual = document.documentElement.getAttribute("data-theme");
|
|
34
82
|
return actual === "light" || actual === "dark" ? actual : defaultMode;
|
|
35
83
|
}
|
|
36
84
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
*/
|
|
85
|
+
/** Select a preference. Storage is best effort; the live choice survives errors. */
|
|
86
|
+
export function applyThemePreference(next: ThemePreference): void {
|
|
87
|
+
const state = getRuntime();
|
|
88
|
+
const previousPreference = resolveThemePreference(
|
|
89
|
+
state.choice,
|
|
90
|
+
state.defaultMode,
|
|
91
|
+
state.respectPrefersColorScheme,
|
|
92
|
+
);
|
|
93
|
+
const previousMode = state.lastMode ?? document.documentElement.getAttribute("data-theme");
|
|
94
|
+
state.choice = next;
|
|
95
|
+
try {
|
|
96
|
+
localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, next);
|
|
97
|
+
} catch {
|
|
98
|
+
// Private browsing or disabled storage must not block theme changes.
|
|
99
|
+
}
|
|
100
|
+
const systemIsDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
|
101
|
+
const mode = resolveColorScheme(next, systemIsDark);
|
|
102
|
+
document.documentElement.setAttribute("data-theme", mode);
|
|
103
|
+
document.documentElement.style.colorScheme = mode;
|
|
104
|
+
state.lastMode = mode;
|
|
105
|
+
if (previousMode !== mode) {
|
|
106
|
+
window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
|
|
107
|
+
}
|
|
108
|
+
if (previousPreference !== next) {
|
|
109
|
+
window.dispatchEvent(new CustomEvent(THEME_PREFERENCE_CHANGED_EVENT));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Existing light/dark callers explicitly select the corresponding preference. */
|
|
67
114
|
export function applyColorScheme(next: ColorSchemeMode): void {
|
|
68
|
-
|
|
69
|
-
document.documentElement.style.colorScheme = next;
|
|
70
|
-
localStorage.setItem(STORAGE_KEY, next);
|
|
71
|
-
window.dispatchEvent(new CustomEvent(COLOR_SCHEME_CHANGED_EVENT));
|
|
115
|
+
applyThemePreference(next);
|
|
72
116
|
}
|
|
73
117
|
|
|
74
|
-
/**
|
|
75
|
-
* Subscribe to color-scheme changes. Returns an unsubscribe function
|
|
76
|
-
* (suitable as a `useEffect` cleanup).
|
|
77
|
-
*/
|
|
78
118
|
export function subscribeColorSchemeChanged(listener: () => void): () => void {
|
|
79
119
|
window.addEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
|
|
80
120
|
return () => window.removeEventListener(COLOR_SCHEME_CHANGED_EVENT, listener);
|
|
81
121
|
}
|
|
122
|
+
|
|
123
|
+
export function subscribeThemePreferenceChanged(listener: () => void): () => void {
|
|
124
|
+
window.addEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
|
|
125
|
+
return () => window.removeEventListener(THEME_PREFERENCE_CHANGED_EVENT, listener);
|
|
126
|
+
}
|
|
@@ -2,127 +2,242 @@
|
|
|
2
2
|
|
|
3
3
|
/** @jsxRuntime automatic */
|
|
4
4
|
/** @jsxImportSource preact */
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
// `@takazudo/zudo-doc/theme-toggle` subpath so hosts can compose it
|
|
8
|
-
// into their own `<Island>` wrappers (or nest it inside another island,
|
|
9
|
-
// e.g. the mobile sidebar footer) without inheriting an extra island
|
|
10
|
-
// layer. The island-wrapped variant for the `./theme` barrel lives in
|
|
11
|
-
// `../theme/theme-toggle.tsx`, which wraps this component.
|
|
12
|
-
//
|
|
13
|
-
// Use the preact hook entrypoints directly — zfb's esbuild step does
|
|
14
|
-
// not alias "react" to "preact/compat", so importing from "react" here
|
|
15
|
-
// would fail to resolve.
|
|
16
|
-
import { useState, useEffect } from "preact/hooks";
|
|
5
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
6
|
+
import { createPortal } from "preact/compat";
|
|
17
7
|
import { useHydrationPending } from "./hydration-pending.js";
|
|
8
|
+
import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
|
|
18
9
|
import {
|
|
19
|
-
|
|
10
|
+
applyThemePreference,
|
|
20
11
|
readColorSchemeFromDom,
|
|
12
|
+
readThemePreference,
|
|
21
13
|
subscribeColorSchemeChanged,
|
|
14
|
+
subscribeThemePreferenceChanged,
|
|
22
15
|
type ColorSchemeMode,
|
|
16
|
+
type ThemePreference,
|
|
23
17
|
} from "./color-scheme-sync.js";
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
const preferences: ThemePreference[] = ["light", "dark", "system"];
|
|
20
|
+
let menuSequence = 0;
|
|
21
|
+
|
|
22
|
+
function PreferenceIcon({ preference }: { preference: ThemePreference }) {
|
|
26
23
|
return (
|
|
27
|
-
<svg
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
strokeLinejoin="round"
|
|
38
|
-
>
|
|
39
|
-
<circle cx="12" cy="12" r="5" />
|
|
40
|
-
<line x1="12" y1="1" x2="12" y2="3" />
|
|
41
|
-
<line x1="12" y1="21" x2="12" y2="23" />
|
|
42
|
-
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
43
|
-
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
44
|
-
<line x1="1" y1="12" x2="3" y2="12" />
|
|
45
|
-
<line x1="21" y1="12" x2="23" y2="12" />
|
|
46
|
-
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
47
|
-
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
24
|
+
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20"
|
|
25
|
+
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2"
|
|
26
|
+
strokeLinecap="square" strokeLinejoin="miter">
|
|
27
|
+
{preference === "light" ? (
|
|
28
|
+
<><circle cx="12" cy="12" r="3.6" /><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" /></>
|
|
29
|
+
) : preference === "dark" ? (
|
|
30
|
+
<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" />
|
|
31
|
+
) : (
|
|
32
|
+
<path d="M2.5 3.5H21.5V16.5H2.5ZM12 16.5V20.5M7.5 20.5H16.5" />
|
|
33
|
+
)}
|
|
48
34
|
</svg>
|
|
49
35
|
);
|
|
50
36
|
}
|
|
51
37
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
height="20"
|
|
59
|
-
viewBox="0 0 24 24"
|
|
60
|
-
fill="none"
|
|
61
|
-
stroke="currentColor"
|
|
62
|
-
strokeWidth="2"
|
|
63
|
-
strokeLinecap="round"
|
|
64
|
-
strokeLinejoin="round"
|
|
65
|
-
>
|
|
66
|
-
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
67
|
-
</svg>
|
|
68
|
-
);
|
|
38
|
+
export interface ThemeToggleLabels {
|
|
39
|
+
appearance: string;
|
|
40
|
+
light: string;
|
|
41
|
+
dark: string;
|
|
42
|
+
system: string;
|
|
43
|
+
systemHelper: string;
|
|
69
44
|
}
|
|
70
45
|
|
|
46
|
+
const englishLabels: ThemeToggleLabels = {
|
|
47
|
+
appearance: "Appearance",
|
|
48
|
+
light: "Light",
|
|
49
|
+
dark: "Dark",
|
|
50
|
+
system: "System",
|
|
51
|
+
systemHelper: "Follows device · currently {mode}",
|
|
52
|
+
};
|
|
53
|
+
|
|
71
54
|
export interface ThemeToggleProps {
|
|
72
55
|
defaultMode?: ColorSchemeMode;
|
|
56
|
+
respectPrefersColorScheme?: boolean;
|
|
57
|
+
labels?: ThemeToggleLabels;
|
|
73
58
|
/** Keep activation pending until the first successful mount. @default true */
|
|
74
59
|
pendingUntilHydrated?: boolean;
|
|
75
60
|
}
|
|
76
61
|
|
|
77
|
-
//
|
|
78
|
-
// to `export { ThemeToggle as default }`, an alias shape zfb's island
|
|
79
|
-
// scanner does not recognize — the island then never registers and the
|
|
80
|
-
// header toggle ships dead (zero hydration). Named exports compile to
|
|
81
|
-
// `export { ThemeToggle }`, which the scanner handles (same pattern as
|
|
82
|
-
// the package's MobileToc island).
|
|
62
|
+
// Keep the named export: zfb's island scanner keys on the ThemeToggle name.
|
|
83
63
|
export function ThemeToggle({
|
|
84
64
|
defaultMode = "dark",
|
|
65
|
+
respectPrefersColorScheme = true,
|
|
66
|
+
labels = englishLabels,
|
|
85
67
|
pendingUntilHydrated = true,
|
|
86
68
|
}: ThemeToggleProps) {
|
|
87
69
|
const pending = useHydrationPending(pendingUntilHydrated);
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
70
|
+
// The menu exists only after a client interaction; allocate across island roots.
|
|
71
|
+
const menuId = useRef("");
|
|
72
|
+
const ensureMenuId = () => {
|
|
73
|
+
if (!menuId.current) menuId.current = `zd-appearance-${++menuSequence}`;
|
|
74
|
+
};
|
|
75
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
76
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
77
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
78
|
+
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
79
|
+
const restoreFocusRef = useRef(false);
|
|
80
|
+
const [preference, setPreference] = useState<ThemePreference>(
|
|
81
|
+
respectPrefersColorScheme ? "system" : defaultMode,
|
|
82
|
+
);
|
|
83
|
+
const [resolved, setResolved] = useState<ColorSchemeMode>(defaultMode);
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
86
|
+
const [placement, setPlacement] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null);
|
|
91
87
|
|
|
92
88
|
useEffect(() => {
|
|
93
|
-
const sync = () =>
|
|
89
|
+
const sync = () => {
|
|
90
|
+
setPreference(readThemePreference(defaultMode, respectPrefersColorScheme));
|
|
91
|
+
setResolved(readColorSchemeFromDom(defaultMode));
|
|
92
|
+
};
|
|
94
93
|
sync();
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
const unsubscribePreference = subscribeThemePreferenceChanged(() => {
|
|
95
|
+
sync();
|
|
96
|
+
setOpen(false);
|
|
97
|
+
});
|
|
98
|
+
const unsubscribeScheme = subscribeColorSchemeChanged(sync);
|
|
99
|
+
return () => {
|
|
100
|
+
unsubscribePreference();
|
|
101
|
+
unsubscribeScheme();
|
|
102
|
+
};
|
|
103
|
+
}, [defaultMode, respectPrefersColorScheme]);
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!open) return;
|
|
107
|
+
// A portaled menu still sits below the third-party token panel's very high
|
|
108
|
+
// stacking tier. The native popover top layer keeps it actionable while
|
|
109
|
+
// that panel is open, without competing with the host's z-index scale.
|
|
110
|
+
const menu = menuRef.current;
|
|
111
|
+
menu?.showPopover?.();
|
|
112
|
+
const position = () => {
|
|
113
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
114
|
+
if (!rect) return;
|
|
115
|
+
const gap = 8;
|
|
116
|
+
const width = Math.min(260, window.innerWidth - gap * 2);
|
|
117
|
+
// Measure after the popover enters the top layer. A fixed guess clips
|
|
118
|
+
// the System helper once the option rows meet the 44px touch target.
|
|
119
|
+
const desiredHeight = menu?.scrollHeight ?? 280;
|
|
120
|
+
const roomBelow = window.innerHeight - rect.bottom - gap * 2;
|
|
121
|
+
const roomAbove = rect.top - gap * 2;
|
|
122
|
+
const above = roomBelow < desiredHeight && roomAbove > roomBelow;
|
|
123
|
+
const maxHeight = Math.max(80, Math.min(desiredHeight, above ? roomAbove : roomBelow));
|
|
124
|
+
setPlacement({
|
|
125
|
+
left: Math.max(gap, Math.min(rect.right - width, window.innerWidth - width - gap)),
|
|
126
|
+
top: above ? Math.max(gap, rect.top - gap - maxHeight) : rect.bottom + gap,
|
|
127
|
+
width,
|
|
128
|
+
maxHeight,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
132
|
+
if (
|
|
133
|
+
!rootRef.current?.contains(event.target as Node) &&
|
|
134
|
+
!menuRef.current?.contains(event.target as Node)
|
|
135
|
+
) setOpen(false);
|
|
136
|
+
};
|
|
137
|
+
const onNavigate = () => setOpen(false);
|
|
138
|
+
position();
|
|
139
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
140
|
+
window.addEventListener("resize", position);
|
|
141
|
+
window.addEventListener("scroll", position, true);
|
|
142
|
+
document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
|
|
143
|
+
return () => {
|
|
144
|
+
if (menu?.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
|
|
145
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
146
|
+
window.removeEventListener("resize", position);
|
|
147
|
+
window.removeEventListener("scroll", position, true);
|
|
148
|
+
document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
|
|
149
|
+
};
|
|
150
|
+
}, [open]); // activeIndex is set before opening; arrow movement focuses directly.
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (open && placement) itemRefs.current[activeIndex]?.focus();
|
|
154
|
+
}, [open, placement]); // Focus once the portaled menu is visible.
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (open || !restoreFocusRef.current) return;
|
|
158
|
+
restoreFocusRef.current = false;
|
|
159
|
+
requestAnimationFrame(() => triggerRef.current?.focus());
|
|
160
|
+
}, [open]);
|
|
107
161
|
|
|
108
|
-
const
|
|
162
|
+
const close = (restoreFocus = false) => {
|
|
163
|
+
restoreFocusRef.current = restoreFocus;
|
|
164
|
+
setOpen(false);
|
|
165
|
+
};
|
|
166
|
+
const select = (next: ThemePreference) => {
|
|
167
|
+
close(true);
|
|
168
|
+
applyThemePreference(next);
|
|
169
|
+
setPreference(next);
|
|
170
|
+
setResolved(readColorSchemeFromDom(defaultMode));
|
|
171
|
+
};
|
|
172
|
+
const move = (index: number) => {
|
|
173
|
+
const next = (index + preferences.length) % preferences.length;
|
|
174
|
+
setActiveIndex(next);
|
|
175
|
+
itemRefs.current[next]?.focus();
|
|
176
|
+
};
|
|
177
|
+
const onMenuKeyDown = (event: KeyboardEvent) => {
|
|
178
|
+
if (event.key === "Escape") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
close(true);
|
|
181
|
+
} else if (event.key === "Tab") {
|
|
182
|
+
// Let the browser move focus before unmounting the focused menu item.
|
|
183
|
+
window.setTimeout(() => close(), 0);
|
|
184
|
+
} else if (event.key === "ArrowDown") {
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
move(activeIndex + 1);
|
|
187
|
+
} else if (event.key === "ArrowUp") {
|
|
188
|
+
event.preventDefault();
|
|
189
|
+
move(activeIndex - 1);
|
|
190
|
+
} else if (event.key === "Home") {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
move(0);
|
|
193
|
+
} else if (event.key === "End") {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
move(2);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
109
198
|
|
|
110
199
|
return (
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
200
|
+
<div ref={rootRef} className="relative inline-flex" data-zd-theme-menu="">
|
|
201
|
+
<button ref={triggerRef} type="button" aria-haspopup="menu" aria-expanded={open}
|
|
202
|
+
aria-controls={open ? menuId.current : undefined}
|
|
203
|
+
aria-label={`${labels.appearance}: ${labels[preference]}`}
|
|
204
|
+
aria-disabled={pending ? "true" : undefined}
|
|
205
|
+
data-zd-pending={pending ? "" : undefined}
|
|
206
|
+
onClick={() => {
|
|
207
|
+
if (pending) return;
|
|
208
|
+
if (open) close(true);
|
|
209
|
+
else { setPlacement(null); ensureMenuId(); setActiveIndex(preferences.indexOf(preference)); setOpen(true); }
|
|
210
|
+
}}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (pending) { if (event.key === "Enter" || event.key === " ") event.preventDefault(); return; }
|
|
213
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
214
|
+
event.preventDefault(); setPlacement(null); ensureMenuId(); setActiveIndex(event.key === "ArrowDown" ? 0 : 2); setOpen(true);
|
|
215
|
+
} else if (event.key === "Escape" && open) { event.preventDefault(); close(true); }
|
|
216
|
+
}}
|
|
217
|
+
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"
|
|
218
|
+
><PreferenceIcon preference={preference} /></button>
|
|
219
|
+
{open && createPortal(<div ref={menuRef} id={menuId.current} role="menu" aria-label={labels.appearance}
|
|
220
|
+
popover={typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype ? "manual" : undefined}
|
|
221
|
+
onKeyDown={onMenuKeyDown}
|
|
222
|
+
className="fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
|
|
223
|
+
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) }}>
|
|
224
|
+
<div className="px-hsp-sm py-vsp-xs text-small font-semibold" aria-hidden="true">{labels.appearance}</div>
|
|
225
|
+
{preferences.map((option, index) => (
|
|
226
|
+
<button key={option} ref={(node) => { itemRefs.current[index] = node; }} type="button"
|
|
227
|
+
role="menuitemradio" aria-checked={preference === option}
|
|
228
|
+
onFocus={() => setActiveIndex(index)} onClick={() => select(option)}
|
|
229
|
+
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`}
|
|
230
|
+
>
|
|
231
|
+
<PreferenceIcon preference={option} />
|
|
232
|
+
<span className="flex-1">{labels[option]}</span>
|
|
233
|
+
<span aria-hidden="true" className="text-accent">{preference === option ? "✓" : ""}</span>
|
|
234
|
+
</button>
|
|
235
|
+
))}
|
|
236
|
+
<div className="px-hsp-sm py-vsp-xs text-small text-muted">
|
|
237
|
+
{labels.systemHelper.replace("{mode}", labels[resolved])}
|
|
238
|
+
</div>
|
|
239
|
+
</div>, document.body)}
|
|
240
|
+
</div>
|
|
120
241
|
);
|
|
121
242
|
}
|
|
122
|
-
// Pin the island marker name to "ThemeToggle" regardless of bundler
|
|
123
|
-
// identifier mangling: zfb's Island() derives the SSR marker via
|
|
124
|
-
// `displayName ?? name`, and esbuild may rename the function when
|
|
125
|
-
// another binding shares the name in the same bundle. Setting
|
|
126
|
-
// displayName explicitly keeps the emitted marker aligned with the
|
|
127
|
-
// island-manifest entry. zudolab/zudo-doc#1446.
|
|
128
243
|
ThemeToggle.displayName = "ThemeToggle";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ThemeToggleLabels } from "./index.js";
|
|
2
|
+
|
|
3
|
+
/** Resolve labels before island serialization so host translation overrides apply. */
|
|
4
|
+
export function themeToggleLabels(
|
|
5
|
+
t: (key: string, locale: string) => string,
|
|
6
|
+
locale: string,
|
|
7
|
+
): ThemeToggleLabels {
|
|
8
|
+
return {
|
|
9
|
+
appearance: t("appearance.title", locale),
|
|
10
|
+
light: t("appearance.light", locale),
|
|
11
|
+
dark: t("appearance.dark", locale),
|
|
12
|
+
system: t("appearance.system", locale),
|
|
13
|
+
systemHelper: t("appearance.systemHelper", locale),
|
|
14
|
+
};
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@takazudo/zudo-doc",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.27.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "zudo-doc framework primitives layer that sits on top of zfb's engine — sidebar, theme, TOC, breadcrumb, layouts, head injection, View Transitions, SSR-skip wrappers (per ADR-003).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -717,9 +717,9 @@
|
|
|
717
717
|
],
|
|
718
718
|
"peerDependencies": {
|
|
719
719
|
"@takazudo/zdtp": "^0.5.2 || ^0.6.0 || ^0.7.0 || ^0.8.0",
|
|
720
|
-
"@takazudo/zfb": "^2.20.
|
|
721
|
-
"@takazudo/zfb-md-wasm": "^2.20.
|
|
722
|
-
"@takazudo/zfb-runtime": "^2.20.
|
|
720
|
+
"@takazudo/zfb": "^2.20.2",
|
|
721
|
+
"@takazudo/zfb-md-wasm": "^2.20.2",
|
|
722
|
+
"@takazudo/zfb-runtime": "^2.20.2",
|
|
723
723
|
"@takazudo/zudo-doc-history-server": "^5.17.2",
|
|
724
724
|
"diff": "^8.0.0",
|
|
725
725
|
"katex": "^0.16.0",
|
|
@@ -757,9 +757,9 @@
|
|
|
757
757
|
},
|
|
758
758
|
"devDependencies": {
|
|
759
759
|
"@takazudo/mdx-formatter": "1.3.0-next.4",
|
|
760
|
-
"@takazudo/zfb": "2.20.
|
|
761
|
-
"@takazudo/zfb-md-wasm": "2.20.
|
|
762
|
-
"@takazudo/zfb-runtime": "2.20.
|
|
760
|
+
"@takazudo/zfb": "2.20.2",
|
|
761
|
+
"@takazudo/zfb-md-wasm": "2.20.2",
|
|
762
|
+
"@takazudo/zfb-runtime": "2.20.2",
|
|
763
763
|
"@types/fs-extra": "^11.0.4",
|
|
764
764
|
"@types/minimist": "^1.2.5",
|
|
765
765
|
"@types/node": "^25.3.5",
|
|
@@ -771,7 +771,7 @@
|
|
|
771
771
|
"typescript": "^5.0.0",
|
|
772
772
|
"vitest": "^4.1.0",
|
|
773
773
|
"zod": "^4.3.6",
|
|
774
|
-
"@takazudo/zudo-doc-history-server": "5.
|
|
774
|
+
"@takazudo/zudo-doc-history-server": "5.27.0"
|
|
775
775
|
},
|
|
776
776
|
"scripts": {
|
|
777
777
|
"gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
|