@takazudo/zudo-doc 6.1.0 → 6.2.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.
@@ -5,7 +5,6 @@ import { hydrationPending } from "./hydration-pending.js";
5
5
  import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
6
6
  import {
7
7
  applyThemePreference,
8
- readColorSchemeFromDom,
9
8
  readThemePreference,
10
9
  subscribeColorSchemeChanged,
11
10
  subscribeThemePreferenceChanged,
@@ -56,7 +55,7 @@ interface AppearanceMenuProps {
56
55
  id: string;
57
56
  labels: ThemeToggleLabels;
58
57
  preference: ReadonlySignal<ThemePreference>;
59
- resolved: ReadonlySignal<ColorSchemeMode>;
58
+ deviceMode: ReadonlySignal<ColorSchemeMode>;
60
59
  activeIndex: ReadonlySignal<number>;
61
60
  triggerRef: Ref<HTMLButtonElement>;
62
61
  rootRef: Ref<HTMLDivElement>;
@@ -65,11 +64,36 @@ interface AppearanceMenuProps {
65
64
  move: (index: number) => void;
66
65
  }
67
66
 
68
- function AppearanceMenu({ id, labels, preference, resolved, activeIndex, triggerRef, rootRef, close, select, move }: AppearanceMenuProps) {
67
+ const MENU_WIDTH_REM = 14.75;
68
+ const VIEWPORT_GAP = 8;
69
+
70
+ function viewportSize() {
71
+ const root = document.documentElement;
72
+ // clientWidth/Height exclude classic scrollbars; innerWidth/Height include them.
73
+ return {
74
+ width: Math.min(window.innerWidth, root.clientWidth || window.innerWidth),
75
+ height: Math.min(window.innerHeight, root.clientHeight || window.innerHeight),
76
+ };
77
+ }
78
+
79
+ function AppearanceMenu({ id, labels, preference, deviceMode, activeIndex, triggerRef, rootRef, close, select, move }: AppearanceMenuProps) {
69
80
  const scope = getScope();
70
81
  const menuRef: Ref<HTMLDivElement> = { current: null };
71
82
  const itemRefs: Ref<HTMLButtonElement>[] = preferences.map(() => ({ current: null }));
72
- const placement = signal({ visibility: "hidden", left: "0px", top: "0px", right: "auto", bottom: "auto", margin: "0", width: "260px", "max-height": "none" });
83
+ const systemDescriptionId = `${id}-system-desc`;
84
+ const placement = signal({ visibility: "hidden", left: "0px", top: "0px", right: "auto", bottom: "auto", margin: "0", width: `${MENU_WIDTH_REM}rem`, "max-height": "none", overflow: "visible" });
85
+
86
+ // preventScroll keeps the document still, so reveal the row inside a last-resort scrolling menu by hand.
87
+ const focusItem = (index: number) => {
88
+ const item = itemRefs[index]?.current;
89
+ const menu = menuRef.current;
90
+ if (!item) return;
91
+ item.focus({ preventScroll: true });
92
+ if (!menu || menu.scrollHeight <= menu.clientHeight) return;
93
+ const bottom = item.offsetTop + item.offsetHeight;
94
+ if (item.offsetTop < menu.scrollTop) menu.scrollTop = item.offsetTop;
95
+ else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;
96
+ };
73
97
 
74
98
  scope.onActivate(() => {
75
99
  const menu = menuRef.current;
@@ -78,37 +102,53 @@ function AppearanceMenu({ id, labels, preference, resolved, activeIndex, trigger
78
102
  const position = () => {
79
103
  const rect = triggerRef.current?.getBoundingClientRect();
80
104
  if (!rect) return;
81
- const gap = 8;
82
- const width = Math.max(0, Math.min(260, window.innerWidth - gap * 2));
83
- const desiredHeight = menu.scrollHeight || 280;
84
- const roomBelow = window.innerHeight - rect.bottom - gap * 2;
105
+ const gap = VIEWPORT_GAP;
106
+ const viewport = viewportSize();
107
+ const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
108
+ const width = Math.max(0, Math.min(MENU_WIDTH_REM * rootFontSize, viewport.width - gap * 2));
109
+ // Measure the natural border-box height at the final width before placing. Read it as
110
+ // content + chrome instead of lifting max-height, which would clamp a scrolled menu to 0.
111
+ menu.style.width = `${width}px`;
112
+ const height = menu.scrollHeight + menu.offsetHeight - menu.clientHeight;
113
+ const roomBelow = viewport.height - rect.bottom - gap * 2;
85
114
  const roomAbove = rect.top - gap * 2;
86
- const above = roomBelow < desiredHeight && roomAbove > roomBelow;
87
- const maxHeight = Math.max(80, Math.min(desiredHeight, above ? roomAbove : roomBelow));
88
- const left = Math.max(gap, Math.min(rect.right - width, window.innerWidth - width - gap));
89
- const top = above ? Math.max(gap, rect.top - gap - maxHeight) : rect.bottom + gap;
90
- placement.value = { visibility: "visible", left: `${left}px`, top: `${top}px`, right: "auto", bottom: "auto", margin: "0", width: `${width}px`, "max-height": `${maxHeight}px` };
115
+ const above = height > roomBelow && roomAbove > roomBelow;
116
+ const room = above ? roomAbove : roomBelow;
117
+ const fits = height <= viewport.height - gap * 2;
118
+ const shownHeight = fits ? height : viewport.height - gap * 2;
119
+ const preferredTop = above ? rect.top - gap - Math.min(height, room) : rect.bottom + gap;
120
+ const top = Math.max(gap, Math.min(preferredTop, viewport.height - gap - shownHeight));
121
+ const left = Math.max(gap, Math.min(rect.right - width, viewport.width - width - gap));
122
+ placement.value = {
123
+ visibility: "visible", left: `${left}px`, top: `${top}px`, right: "auto", bottom: "auto", margin: "0", width: `${width}px`,
124
+ // Scrolling is only a last resort when the natural menu is taller than the viewport.
125
+ "max-height": fits ? "none" : `${shownHeight}px`, overflow: fits ? "visible" : "auto",
126
+ };
91
127
  };
92
128
  const onPointerDown = (event: PointerEvent) => {
93
129
  if (!rootRef.current?.contains(event.target as Node) && !menu.contains(event.target as Node)) close();
94
130
  };
95
131
  const onNavigate = () => close();
132
+ const onScroll = (event: Event) => { if (event.target !== menu) position(); };
96
133
  position();
97
134
  // The placement signal commits visibility after this activation callback.
98
135
  // Focus only after the visible popover has reached the browser layout.
99
136
  const focusFrame = requestAnimationFrame(() => {
100
- if (!scope.abortSignal.aborted) itemRefs[activeIndex.value]?.current?.focus();
137
+ if (!scope.abortSignal.aborted) focusItem(activeIndex.value);
101
138
  });
139
+ const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => position());
140
+ resizeObserver?.observe(menu);
102
141
  document.addEventListener("pointerdown", onPointerDown);
103
142
  window.addEventListener("resize", position);
104
- window.addEventListener("scroll", position, true);
143
+ window.addEventListener("scroll", onScroll, true);
105
144
  document.addEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
106
145
  return () => {
107
146
  cancelAnimationFrame(focusFrame);
147
+ resizeObserver?.disconnect();
108
148
  if (menu.hidePopover && menu.matches(":popover-open")) menu.hidePopover();
109
149
  document.removeEventListener("pointerdown", onPointerDown);
110
150
  window.removeEventListener("resize", position);
111
- window.removeEventListener("scroll", position, true);
151
+ window.removeEventListener("scroll", onScroll, true);
112
152
  document.removeEventListener(AFTER_NAVIGATE_EVENT, onNavigate);
113
153
  };
114
154
  });
@@ -126,26 +166,29 @@ function AppearanceMenu({ id, labels, preference, resolved, activeIndex, trigger
126
166
  keyboard.preventDefault();
127
167
  const next = keyboard.key === "Home" ? 0 : keyboard.key === "End" ? 2 : activeIndex.value + (keyboard.key === "ArrowDown" ? 1 : -1);
128
168
  move(next);
129
- itemRefs[(next + preferences.length) % preferences.length]?.current?.focus();
169
+ focusItem((next + preferences.length) % preferences.length);
130
170
  }
131
171
  };
132
172
 
133
173
  return <div ref={menuRef} id={id} role="menu" aria-label={labels.appearance}
134
174
  on:keydown={onMenuKeyDown}
135
- class="fixed z-tooltip overflow-y-auto rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
175
+ class="fixed z-tooltip flex flex-col gap-hsp-2xs rounded-lg border border-muted bg-surface p-hsp-xs text-fg shadow-lg"
136
176
  style={placement} popover="manual">
137
- <div class="px-hsp-sm py-vsp-xs text-small font-semibold" aria-hidden="true">{labels.appearance}</div>
138
177
  {preferences.map((option, index) => <button ref={itemRefs[index]} type="button"
139
- role="menuitemradio" aria-checked={computed(() => preference.value === option)}
178
+ role="menuitemradio" tabindex={-1} aria-checked={computed(() => preference.value === option)}
179
+ aria-describedby={option === "system" ? systemDescriptionId : undefined}
140
180
  on:focus={() => move(index)} on:click={() => select(option)}
141
- class={computed(() => `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference.value === option ? "bg-accent/10" : ""} hover:bg-accent/10 focus-visible:bg-accent/10 focus-visible:outline-2 focus-visible:outline-accent`)}>
181
+ class={computed(() => `flex min-h-[44px] w-full items-center gap-hsp-sm rounded px-hsp-sm text-left text-small ${preference.value === option ? "bg-accent/10" : "hover:bg-accent/5"} focus-visible:outline-2 focus-visible:outline-accent`)}>
142
182
  <PreferenceIcon preference={option} />
143
- <span class="flex-1">{labels[option]}</span>
144
- <span aria-hidden="true" class="text-accent">{computed(() => preference.value === option ? "✓" : "")}</span>
183
+ <span class="min-w-0 flex-1 break-words">{labels[option]}</span>
184
+ {option === "system"
185
+ ? <span aria-hidden="true" data-zd-device-mode="" class="shrink-0 text-caption text-muted">{computed(() => labels[deviceMode.value])}</span>
186
+ : null}
187
+ <span aria-hidden="true" class="w-[1.25rem] shrink-0 text-center text-accent">{computed(() => preference.value === option ? "✓" : "")}</span>
145
188
  </button>)}
146
- <div class="px-hsp-sm py-vsp-xs text-small text-muted">
147
- {computed(() => labels.systemHelper.replace("{mode}", labels[resolved.value]))}
148
- </div>
189
+ <span id={systemDescriptionId} hidden>
190
+ {computed(() => labels.systemHelper.replace("{mode}", labels[deviceMode.value]))}
191
+ </span>
149
192
  </div>;
150
193
  }
151
194
 
@@ -156,34 +199,35 @@ export function ThemeToggle({ defaultMode = "dark", respectPrefersColorScheme =
156
199
  const rootRef: Ref<HTMLDivElement> = { current: null };
157
200
  const triggerRef: Ref<HTMLButtonElement> = { current: null };
158
201
  const preference = signal<ThemePreference>(respectPrefersColorScheme ? "system" : defaultMode);
159
- const resolved = signal<ColorSchemeMode>(defaultMode);
202
+ const deviceMode = signal<ColorSchemeMode>(defaultMode);
160
203
  const open = signal(false);
161
204
  const activeIndex = signal(0);
162
205
  let menuId = "";
163
206
  const ensureMenuId = () => { if (!menuId) menuId = `zd-appearance-${++menuSequence}`; };
164
207
 
165
208
  scope.onActivate(() => {
166
- const sync = () => {
167
- preference.value = readThemePreference(defaultMode, respectPrefersColorScheme);
168
- resolved.value = readColorSchemeFromDom(defaultMode);
169
- };
209
+ const sync = () => { preference.value = readThemePreference(defaultMode, respectPrefersColorScheme); };
170
210
  sync();
211
+ // The device mode is independent of the page theme: it stays truthful under a manual Light/Dark choice.
212
+ const deviceQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
213
+ const syncDevice = () => { deviceMode.value = deviceQuery?.matches ? "dark" : "light"; };
214
+ if (deviceQuery) syncDevice();
215
+ deviceQuery?.addEventListener?.("change", syncDevice);
171
216
  const unsubscribePreference = subscribeThemePreferenceChanged(() => { sync(); open.value = false; });
172
217
  const unsubscribeScheme = subscribeColorSchemeChanged(sync);
173
- return () => { unsubscribePreference(); unsubscribeScheme(); };
218
+ return () => { unsubscribePreference(); unsubscribeScheme(); deviceQuery?.removeEventListener?.("change", syncDevice); };
174
219
  });
175
220
 
176
221
  const close = (restoreFocus = false) => {
177
222
  open.value = false;
178
223
  if (restoreFocus) requestAnimationFrame(() => {
179
- if (!scope.abortSignal.aborted && !open.value) triggerRef.current?.focus();
224
+ if (!scope.abortSignal.aborted && !open.value) triggerRef.current?.focus({ preventScroll: true });
180
225
  });
181
226
  };
182
227
  const select = (next: ThemePreference) => {
183
228
  close(true);
184
229
  applyThemePreference(next);
185
230
  preference.value = next;
186
- resolved.value = readColorSchemeFromDom(defaultMode);
187
231
  };
188
232
  const move = (index: number) => { activeIndex.value = (index + preferences.length) % preferences.length; };
189
233
  const show = (index: number) => { ensureMenuId(); move(index); open.value = true; };
@@ -210,7 +254,7 @@ export function ThemeToggle({ defaultMode = "dark", respectPrefersColorScheme =
210
254
  <Show when={computed(() => preference.value === "dark")}>{() => <PreferenceIcon preference="dark" />}</Show>
211
255
  <Show when={computed(() => preference.value === "system")}>{() => <PreferenceIcon preference="system" />}</Show>
212
256
  </button>
213
- <Show when={open}>{() => <AppearanceMenu id={menuId} labels={labels} preference={preference} resolved={resolved} activeIndex={activeIndex} triggerRef={triggerRef} rootRef={rootRef} close={close} select={select} move={move} />}</Show>
257
+ <Show when={open}>{() => <AppearanceMenu id={menuId} labels={labels} preference={preference} deviceMode={deviceMode} activeIndex={activeIndex} triggerRef={triggerRef} rootRef={rootRef} close={close} select={select} move={move} />}</Show>
214
258
  </div>;
215
259
  }
216
260
  ThemeToggle.displayName = "ThemeToggle";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "6.1.0",
3
+ "version": "6.2.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",
@@ -733,9 +733,9 @@
733
733
  "peerDependencies": {
734
734
  "@modelcontextprotocol/sdk": "1.32.1",
735
735
  "@takazudo/zdtp": "^0.5.2 || ^0.6.0 || ^0.7.0 || ^0.8.0",
736
- "@takazudo/zfb": "^4.3.2",
737
- "@takazudo/zfb-md-wasm": "^4.3.2",
738
- "@takazudo/zfb-runtime": "^4.3.2",
736
+ "@takazudo/zfb": "^4.5.0",
737
+ "@takazudo/zfb-md-wasm": "^4.5.0",
738
+ "@takazudo/zfb-runtime": "^4.5.0",
739
739
  "@takazudo/zudo-doc-history-server": "^6.0.0",
740
740
  "diff": "^8.0.0",
741
741
  "katex": "^0.16.0",
@@ -769,16 +769,16 @@
769
769
  "parse5": "^8.0.1",
770
770
  "picocolors": "^1.1.1",
771
771
  "pluralize": "^8.0.0",
772
- "smol-toml": "^1.8.0",
772
+ "smol-toml": "^1.9.1",
773
773
  "tsx": "^4.21.0",
774
774
  "yaml": "^2.9.0"
775
775
  },
776
776
  "devDependencies": {
777
777
  "@modelcontextprotocol/sdk": "1.32.1",
778
778
  "@takazudo/mdx-formatter": "1.3.0",
779
- "@takazudo/zfb": "4.3.2",
780
- "@takazudo/zfb-md-wasm": "4.3.2",
781
- "@takazudo/zfb-runtime": "4.3.2",
779
+ "@takazudo/zfb": "4.5.0",
780
+ "@takazudo/zfb-md-wasm": "4.5.0",
781
+ "@takazudo/zfb-runtime": "4.5.0",
782
782
  "@types/fs-extra": "^11.0.4",
783
783
  "@types/minimist": "^1.2.5",
784
784
  "@types/node": "^25.3.5",
@@ -788,7 +788,7 @@
788
788
  "typescript": "^5.0.0",
789
789
  "vitest": "^4.1.0",
790
790
  "zod": "^4.3.6",
791
- "@takazudo/zudo-doc-history-server": "6.1.0"
791
+ "@takazudo/zudo-doc-history-server": "6.2.0"
792
792
  },
793
793
  "scripts": {
794
794
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",
@@ -23,8 +23,7 @@
23
23
  "strictPropertyInitialization": true,
24
24
  "noImplicitThis": true,
25
25
  "useUnknownInCatchVariables": true,
26
- "alwaysStrict": true,
27
- "baseUrl": "."
26
+ "alwaysStrict": true
28
27
  },
29
28
  "files": ["./zfb-config-shim.d.ts", "./virtual-modules.d.ts"]
30
29
  }