@takazudo/zudo-doc 5.26.5 → 5.28.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 +32 -1
- package/dist/compiled.css +18 -0
- package/dist/config-assertions/index.d.ts +13 -0
- package/dist/config-assertions/index.js +9 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +4 -1
- package/dist/extract-headings/index.d.ts +2 -0
- package/dist/extract-headings/index.js +165 -16
- 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/internal/search-index/collect.js +15 -12
- package/dist/plugins/internal/search-index/types.d.ts +25 -3
- package/dist/plugins/internal/search-index/types.js +1 -1
- package/dist/preset.d.ts +8 -0
- package/dist/preset.js +4 -1
- package/dist/safelist.css +1 -1
- package/dist/search-widget/index.js +1 -1
- package/dist/settings.d.ts +1 -0
- package/dist/sidebar-toggle-island/index.d.ts +4 -1
- package/dist/sidebar-toggle-island/index.js +5 -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 +219 -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 +14 -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 +217 -89
- package/eject/theme-toggle/labels.ts +15 -0
- package/package.json +9 -9
|
@@ -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,255 @@
|
|
|
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
|
+
// The menu is portaled to document.body (see createPortal below), so this
|
|
181
|
+
// keydown bubbles all the way to `document` — where the mobile drawer's own
|
|
182
|
+
// Escape-to-close listener lives (sidebar-toggle-island/index.tsx). Without
|
|
183
|
+
// stopPropagation, closing just the menu would also close the drawer
|
|
184
|
+
// (zudolab/zudo-doc#4393).
|
|
185
|
+
event.stopPropagation();
|
|
186
|
+
close(true);
|
|
187
|
+
} else if (event.key === "Tab") {
|
|
188
|
+
// Let the browser move focus before unmounting the focused menu item.
|
|
189
|
+
window.setTimeout(() => close(), 0);
|
|
190
|
+
} else if (event.key === "ArrowDown") {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
move(activeIndex + 1);
|
|
193
|
+
} else if (event.key === "ArrowUp") {
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
move(activeIndex - 1);
|
|
196
|
+
} else if (event.key === "Home") {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
move(0);
|
|
199
|
+
} else if (event.key === "End") {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
move(2);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
109
204
|
|
|
110
205
|
return (
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
206
|
+
<div ref={rootRef} className="relative inline-flex" data-zd-theme-menu="">
|
|
207
|
+
<button ref={triggerRef} type="button" aria-haspopup="menu" aria-expanded={open}
|
|
208
|
+
aria-controls={open ? menuId.current : undefined}
|
|
209
|
+
aria-label={`${labels.appearance}: ${labels[preference]}`}
|
|
210
|
+
aria-disabled={pending ? "true" : undefined}
|
|
211
|
+
data-zd-pending={pending ? "" : undefined}
|
|
212
|
+
onClick={() => {
|
|
213
|
+
if (pending) return;
|
|
214
|
+
if (open) close(true);
|
|
215
|
+
else { setPlacement(null); ensureMenuId(); setActiveIndex(preferences.indexOf(preference)); setOpen(true); }
|
|
216
|
+
}}
|
|
217
|
+
onKeyDown={(event) => {
|
|
218
|
+
if (pending) { if (event.key === "Enter" || event.key === " ") event.preventDefault(); return; }
|
|
219
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
220
|
+
event.preventDefault(); setPlacement(null); ensureMenuId(); setActiveIndex(event.key === "ArrowDown" ? 0 : 2); setOpen(true);
|
|
221
|
+
} else if (event.key === "Escape" && open) {
|
|
222
|
+
// Same layered-ownership fix as onMenuKeyDown above: the trigger sits
|
|
223
|
+
// inside the mobile drawer (unlike the portaled menu), so its keydown
|
|
224
|
+
// bubbles straight to the drawer's document-level Escape listener.
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
event.stopPropagation();
|
|
227
|
+
close(true);
|
|
228
|
+
}
|
|
229
|
+
}}
|
|
230
|
+
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"
|
|
231
|
+
><PreferenceIcon preference={preference} /></button>
|
|
232
|
+
{open && createPortal(<div ref={menuRef} id={menuId.current} role="menu" aria-label={labels.appearance}
|
|
233
|
+
popover={typeof HTMLElement !== "undefined" && "showPopover" in HTMLElement.prototype ? "manual" : undefined}
|
|
234
|
+
onKeyDown={onMenuKeyDown}
|
|
235
|
+
className="fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
|
|
236
|
+
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) }}>
|
|
237
|
+
<div className="px-hsp-sm py-vsp-xs text-small font-semibold" aria-hidden="true">{labels.appearance}</div>
|
|
238
|
+
{preferences.map((option, index) => (
|
|
239
|
+
<button key={option} ref={(node) => { itemRefs.current[index] = node; }} type="button"
|
|
240
|
+
role="menuitemradio" aria-checked={preference === option}
|
|
241
|
+
onFocus={() => setActiveIndex(index)} onClick={() => select(option)}
|
|
242
|
+
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`}
|
|
243
|
+
>
|
|
244
|
+
<PreferenceIcon preference={option} />
|
|
245
|
+
<span className="flex-1">{labels[option]}</span>
|
|
246
|
+
<span aria-hidden="true" className="text-accent">{preference === option ? "✓" : ""}</span>
|
|
247
|
+
</button>
|
|
248
|
+
))}
|
|
249
|
+
<div className="px-hsp-sm py-vsp-xs text-small text-muted">
|
|
250
|
+
{labels.systemHelper.replace("{mode}", labels[resolved])}
|
|
251
|
+
</div>
|
|
252
|
+
</div>, document.body)}
|
|
253
|
+
</div>
|
|
120
254
|
);
|
|
121
255
|
}
|
|
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
256
|
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.28.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.
|
|
721
|
-
"@takazudo/zfb-md-wasm": "^2.
|
|
722
|
-
"@takazudo/zfb-runtime": "^2.
|
|
720
|
+
"@takazudo/zfb": "^2.21.1",
|
|
721
|
+
"@takazudo/zfb-md-wasm": "^2.21.1",
|
|
722
|
+
"@takazudo/zfb-runtime": "^2.21.1",
|
|
723
723
|
"@takazudo/zudo-doc-history-server": "^5.17.2",
|
|
724
724
|
"diff": "^8.0.0",
|
|
725
725
|
"katex": "^0.16.0",
|
|
@@ -756,10 +756,10 @@
|
|
|
756
756
|
"yaml": "^2.9.0"
|
|
757
757
|
},
|
|
758
758
|
"devDependencies": {
|
|
759
|
-
"@takazudo/mdx-formatter": "1.3.0
|
|
760
|
-
"@takazudo/zfb": "2.
|
|
761
|
-
"@takazudo/zfb-md-wasm": "2.
|
|
762
|
-
"@takazudo/zfb-runtime": "2.
|
|
759
|
+
"@takazudo/mdx-formatter": "1.3.0",
|
|
760
|
+
"@takazudo/zfb": "2.21.1",
|
|
761
|
+
"@takazudo/zfb-md-wasm": "2.21.1",
|
|
762
|
+
"@takazudo/zfb-runtime": "2.21.1",
|
|
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.28.0"
|
|
775
775
|
},
|
|
776
776
|
"scripts": {
|
|
777
777
|
"gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
|