@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/dist/compiled.css +18 -0
  3. package/dist/config-assertions/index.d.ts +13 -0
  4. package/dist/config-assertions/index.js +9 -0
  5. package/dist/config.d.ts +14 -0
  6. package/dist/config.js +4 -1
  7. package/dist/extract-headings/index.d.ts +2 -0
  8. package/dist/extract-headings/index.js +165 -16
  9. package/dist/header/header.js +47 -19
  10. package/dist/header-with-defaults/index.js +5 -1
  11. package/dist/i18n-defaults/index.js +10 -0
  12. package/dist/plugins/internal/search-index/collect.js +15 -12
  13. package/dist/plugins/internal/search-index/types.d.ts +25 -3
  14. package/dist/plugins/internal/search-index/types.js +1 -1
  15. package/dist/preset.d.ts +8 -0
  16. package/dist/preset.js +4 -1
  17. package/dist/safelist.css +1 -1
  18. package/dist/search-widget/index.js +1 -1
  19. package/dist/settings.d.ts +1 -0
  20. package/dist/sidebar-toggle-island/index.d.ts +4 -1
  21. package/dist/sidebar-toggle-island/index.js +5 -0
  22. package/dist/sidebar-tree-island/index.d.ts +4 -1
  23. package/dist/sidebar-tree-island/index.js +5 -5
  24. package/dist/sidebar-with-defaults/index.js +3 -0
  25. package/dist/theme/color-scheme-provider.js +40 -10
  26. package/dist/theme/theme-toggle.d.ts +1 -1
  27. package/dist/theme-toggle/color-scheme-sync.d.ts +19 -39
  28. package/dist/theme-toggle/color-scheme-sync.js +81 -6
  29. package/dist/theme-toggle/index.d.ts +10 -1
  30. package/dist/theme-toggle/index.js +219 -58
  31. package/dist/theme-toggle/labels.d.ts +3 -0
  32. package/dist/theme-toggle/labels.js +12 -0
  33. package/eject/header/header.tsx +56 -19
  34. package/eject/sidebar-toggle-island/index.tsx +14 -0
  35. package/eject/sidebar-tree-island/index.tsx +8 -6
  36. package/eject/theme-toggle/color-scheme-sync.ts +108 -63
  37. package/eject/theme-toggle/index.tsx +217 -89
  38. package/eject/theme-toggle/labels.ts +15 -0
  39. package/package.json +9 -9
@@ -1,81 +1,126 @@
1
- // Shared color-scheme state helpers for ThemeToggle (#2012 E3).
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
- const STORAGE_KEY = "zudo-doc-theme";
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
- * Read the active color scheme from `<html data-theme>`. Falls back to
27
- * `defaultMode` when the attribute is missing or holds an unexpected
28
- * value (e.g. before the ColorSchemeProvider bootstrap script ran).
29
- */
30
- export function readColorSchemeFromDom(
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
- * Apply `next` as the active color scheme: mutate the DOM, persist the
39
- * preference, and notify every subscriber (including other mounted
40
- * ThemeToggle instances and the zdtp design-token panel) via the
41
- * `color-scheme-changed` window event.
42
- *
43
- * Tweak-state reconciliation is intentionally NOT done here (#2037). The zdtp
44
- * panel owns its own storage lifecycle and current persisted-state contract;
45
- * its own `color-scheme-changed` listener clears applied inline
46
- * styles and re-seeds the color slice from the newly active scheme. An
47
- * earlier version of this function deleted `zudo-doc-tweak-state` + `-v2` on
48
- * every toggle, which (a) targeted stale keys after zdtp moved to v3 — so it
49
- * no longer did anything — and (b) when it did fire, wiped the whole envelope
50
- * including scheme-independent spacing/typography/size tweaks, contradicting
51
- * the documented carry-over guarantee. So the host no longer touches zdtp's
52
- * private storage keys.
53
- *
54
- * The design-token-panel bootstrap ALSO listens for this event and, on toggle,
55
- * destroys + reconfigures the panel with the new mode's mode-scoped semantic
56
- * DEFAULTS (see `design-token-panel-bootstrap.ts` + the host's
57
- * `buildDesignTokenPanelConfig`, #2610). That keeps the panel's per-mode
58
- * defaults faithful. Saved Color overrides are scheme- and mode-scoped:
59
- * the package-default builder declares separate Default Light / Default Dark
60
- * identities in panelSettings.colorMode, so an edit made in one mode does not
61
- * replace the other mode's default or saved mapping. Returning to a mode
62
- * restores that identity's saved choice. Palette, Spacing, Font, and Size
63
- * overrides remain shared across light/dark within the active theme pack.
64
- * The browser contract lives in e2e/theme-panel-persistence.spec.ts (#3980).
65
- * See zudo-doc#2037 / #2610.
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
- document.documentElement.setAttribute("data-theme", next);
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
- // BARE (non-island-wrapped) theme toggle — the single ThemeToggle
6
- // implementation (#2012 E2). Published as the dedicated
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
- applyColorScheme,
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
- function SunIcon() {
19
+ const preferences: ThemePreference[] = ["light", "dark", "system"];
20
+ let menuSequence = 0;
21
+
22
+ function PreferenceIcon({ preference }: { preference: ThemePreference }) {
26
23
  return (
27
- <svg
28
- aria-hidden="true"
29
- xmlns="http://www.w3.org/2000/svg"
30
- width="20"
31
- height="20"
32
- viewBox="0 0 24 24"
33
- fill="none"
34
- stroke="currentColor"
35
- strokeWidth="2"
36
- strokeLinecap="round"
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
- function MoonIcon() {
53
- return (
54
- <svg
55
- aria-hidden="true"
56
- xmlns="http://www.w3.org/2000/svg"
57
- width="20"
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
- // NAMED export (not default) on purpose: tsup compiles a default export
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
- // Initial state must match server render to avoid hydration mismatch.
89
- // Actual theme is synced from DOM in useEffect below.
90
- const [mode, setMode] = useState<ColorSchemeMode>(defaultMode);
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 = () => setMode(readColorSchemeFromDom(defaultMode));
89
+ const sync = () => {
90
+ setPreference(readThemePreference(defaultMode, respectPrefersColorScheme));
91
+ setResolved(readColorSchemeFromDom(defaultMode));
92
+ };
94
93
  sync();
95
- // Cross-instance sync (#2012 E3): every mounted toggle re-reads the
96
- // DOM whenever any instance (or the zdtp panel) applies a scheme,
97
- // so the header toggle and the sidebar-footer toggle never disagree.
98
- return subscribeColorSchemeChanged(sync);
99
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
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
- function toggle() {
102
- if (pending) return;
103
- const next = mode === "dark" ? "light" : "dark";
104
- setMode(next);
105
- applyColorScheme(next);
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 nextMode = mode === "dark" ? "light" : "dark";
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
- <button
112
- onClick={toggle}
113
- aria-label={`Switch to ${nextMode} mode`}
114
- aria-disabled={pending ? "true" : undefined}
115
- data-zd-pending={pending ? "" : undefined}
116
- className="text-muted hover:text-fg transition-colors p-hsp-sm focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
117
- >
118
- {mode === "dark" ? <SunIcon /> : <MoonIcon />}
119
- </button>
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.26.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.20.1",
721
- "@takazudo/zfb-md-wasm": "^2.20.1",
722
- "@takazudo/zfb-runtime": "^2.20.1",
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-next.4",
760
- "@takazudo/zfb": "2.20.1",
761
- "@takazudo/zfb-md-wasm": "2.20.1",
762
- "@takazudo/zfb-runtime": "2.20.1",
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.26.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",