@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.
@@ -2,127 +2,242 @@
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
+ 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
- <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>
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.26.4",
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.1",
721
- "@takazudo/zfb-md-wasm": "^2.20.1",
722
- "@takazudo/zfb-runtime": "^2.20.1",
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.1",
761
- "@takazudo/zfb-md-wasm": "2.20.1",
762
- "@takazudo/zfb-runtime": "2.20.1",
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.26.4"
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",