@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.
- package/CHANGELOG.md +25 -0
- package/README.md +2 -2
- package/dist/code-syntax/mermaid-init-script.d.ts +2 -2
- package/dist/code-syntax/mermaid-init-script.js +1 -1
- package/dist/compiled.css +8 -0
- package/dist/sidebar-toggle-island/index.js +5 -0
- package/dist/sidebar-tree-island/index.js +106 -90
- package/dist/sidebar-tree-island/sidebar-scroll-preserve.d.ts +10 -8
- package/dist/sidebar-tree-island/sidebar-scroll-preserve.js +424 -24
- package/dist/sidebar-utils/index.d.ts +8 -0
- package/dist/sidebar-utils/index.js +51 -6
- package/dist/theme-toggle/index.js +76 -27
- package/dist/wind.json +2 -0
- package/eject/sidebar-toggle-island/index.tsx +5 -0
- package/eject/sidebar-tree-island/index.tsx +16 -2
- package/eject/sidebar-tree-island/sidebar-scroll-preserve.ts +482 -35
- package/eject/theme-toggle/index.tsx +79 -35
- package/package.json +9 -9
- package/tsconfig.base.json +1 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
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 =
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
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)
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
|
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" : "
|
|
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
|
-
|
|
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
|
-
<
|
|
147
|
-
{computed(() => labels.systemHelper.replace("{mode}", labels[
|
|
148
|
-
</
|
|
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
|
|
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}
|
|
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.
|
|
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.
|
|
737
|
-
"@takazudo/zfb-md-wasm": "^4.
|
|
738
|
-
"@takazudo/zfb-runtime": "^4.
|
|
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.
|
|
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.
|
|
780
|
-
"@takazudo/zfb-md-wasm": "4.
|
|
781
|
-
"@takazudo/zfb-runtime": "4.
|
|
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.
|
|
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",
|
package/tsconfig.base.json
CHANGED