@takazudo/zudo-doc 1.0.2 → 1.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.
@@ -0,0 +1,219 @@
1
+ "use client";
2
+
3
+ /** @jsxRuntime automatic */
4
+ /** @jsxImportSource preact */
5
+
6
+ // Image-enlarge island — relocated from src/components/image-enlarge.tsx
7
+ // (host showcase) into the package as part of Package-First Wave 3 (S3,
8
+ // epic #2344). Uses shared hook + constants from S1a foundation:
9
+ // - useModalDialog from @takazudo/zudo-doc/use-modal-dialog
10
+ // - IMAGE_ENLARGE_DIALOG_CLASS / ENLARGE_DIALOG_STYLE from @takazudo/zudo-doc/island-types
11
+ //
12
+ // The `/api/ai-chat` endpoint stays host-side (showcase-only).
13
+ // CSS blocks (.zd-enlargeable, .zd-enlarge-btn, .zd-enlarge-dialog*) are
14
+ // shipped in @takazudo/zudo-doc/features.css (moved from src/styles/global.css).
15
+
16
+ import { useState, useEffect } from "preact/compat";
17
+ import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
18
+ import { useModalDialog } from "../use-modal-dialog/index.js";
19
+ import {
20
+ IMAGE_ENLARGE_DIALOG_CLASS,
21
+ ENLARGE_DIALOG_STYLE,
22
+ } from "../island-types/index.js";
23
+
24
+ interface ImageData {
25
+ src: string;
26
+ currentSrc: string;
27
+ srcset?: string;
28
+ sizes?: string;
29
+ alt: string;
30
+ naturalWidth: number;
31
+ naturalHeight: number;
32
+ }
33
+
34
+ export function ImageEnlarge() {
35
+ const [imgData, setImgData] = useState<ImageData | null>(null);
36
+
37
+ // Eligibility detection: toggle .zd-enlarge-btn[hidden] per image
38
+ useEffect(() => {
39
+ const observedImages = new Set<HTMLImageElement>();
40
+ const sharedResizeObserver = new ResizeObserver((entries) => {
41
+ for (const entry of entries) {
42
+ evaluateEligibility(entry.target as HTMLImageElement);
43
+ }
44
+ });
45
+ let mutationObserver: MutationObserver | null = null;
46
+ let resizeTimer = 0;
47
+ let loadAbortController = new AbortController();
48
+
49
+ function evaluateEligibility(img: HTMLImageElement) {
50
+ const container = img.closest(".zd-enlargeable");
51
+ if (!container) return;
52
+ const btn = container.querySelector(".zd-enlarge-btn") as HTMLElement | null;
53
+ if (!btn) return;
54
+ const eligible = img.naturalWidth > img.clientWidth * window.devicePixelRatio;
55
+ if (eligible) {
56
+ btn.removeAttribute("hidden");
57
+ } else {
58
+ btn.setAttribute("hidden", "");
59
+ }
60
+ }
61
+
62
+ function observeImage(img: HTMLImageElement) {
63
+ if (observedImages.has(img)) return;
64
+ observedImages.add(img);
65
+ sharedResizeObserver.observe(img);
66
+ if (img.complete) {
67
+ evaluateEligibility(img);
68
+ } else {
69
+ img.addEventListener(
70
+ "load",
71
+ () => evaluateEligibility(img),
72
+ { once: true, signal: loadAbortController.signal },
73
+ );
74
+ }
75
+ }
76
+
77
+ function scanContent() {
78
+ const scope = document.querySelector("main .zd-content");
79
+ if (!scope) return;
80
+ scope.querySelectorAll<HTMLImageElement>(".zd-enlargeable img").forEach(observeImage);
81
+ }
82
+
83
+ function startObserving() {
84
+ const scope = document.querySelector("main .zd-content");
85
+ if (scope) {
86
+ mutationObserver = new MutationObserver((mutations) => {
87
+ for (const mutation of mutations) {
88
+ for (const node of mutation.addedNodes) {
89
+ if (!(node instanceof Element)) continue;
90
+ if (node.matches(".zd-enlargeable")) {
91
+ node.querySelectorAll<HTMLImageElement>("img").forEach(observeImage);
92
+ }
93
+ node.querySelectorAll<HTMLImageElement>(".zd-enlargeable img").forEach(observeImage);
94
+ }
95
+ }
96
+ });
97
+ mutationObserver.observe(scope, { childList: true, subtree: true });
98
+ }
99
+ scanContent();
100
+ }
101
+
102
+ function handleWindowResize() {
103
+ clearTimeout(resizeTimer);
104
+ resizeTimer = window.setTimeout(() => {
105
+ observedImages.forEach((img) => evaluateEligibility(img));
106
+ }, 150);
107
+ }
108
+
109
+ function handleAfterSwap() {
110
+ sharedResizeObserver.disconnect();
111
+ observedImages.clear();
112
+ mutationObserver?.disconnect();
113
+ mutationObserver = null;
114
+ loadAbortController.abort();
115
+ loadAbortController = new AbortController();
116
+ startObserving();
117
+ }
118
+
119
+ startObserving();
120
+ window.addEventListener("resize", handleWindowResize);
121
+ document.addEventListener(AFTER_NAVIGATE_EVENT, handleAfterSwap);
122
+
123
+ return () => {
124
+ sharedResizeObserver.disconnect();
125
+ observedImages.clear();
126
+ mutationObserver?.disconnect();
127
+ loadAbortController.abort();
128
+ window.removeEventListener("resize", handleWindowResize);
129
+ document.removeEventListener(AFTER_NAVIGATE_EVENT, handleAfterSwap);
130
+ clearTimeout(resizeTimer);
131
+ };
132
+ }, []);
133
+
134
+ useEffect(() => {
135
+ function handleDocumentClick(e: MouseEvent) {
136
+ const sel = window.getSelection();
137
+ if (sel && !sel.isCollapsed) return;
138
+ const target = e.target as Element;
139
+ const container = target.closest(".zd-enlargeable");
140
+ if (!container) return;
141
+ if (!target.closest(".zd-enlarge-btn") && !target.closest("img")) return;
142
+ const btn = container.querySelector(".zd-enlarge-btn") as HTMLElement | null;
143
+ if (!btn || btn.hasAttribute("hidden")) return;
144
+ const img = container.querySelector("img") as HTMLImageElement | null;
145
+ if (!img) return;
146
+ setImgData({
147
+ src: img.src,
148
+ currentSrc: img.currentSrc,
149
+ srcset: img.srcset || undefined,
150
+ sizes: img.sizes || undefined,
151
+ alt: img.alt,
152
+ naturalWidth: img.naturalWidth,
153
+ naturalHeight: img.naturalHeight,
154
+ });
155
+ }
156
+ document.addEventListener("click", handleDocumentClick);
157
+ return () => document.removeEventListener("click", handleDocumentClick);
158
+ }, []);
159
+
160
+ const handleClose = () => setImgData(null);
161
+
162
+ const { dialogRef, handleBackdropClick } = useModalDialog({
163
+ isOpen: imgData !== null,
164
+ onClose: handleClose,
165
+ navigateEvent: AFTER_NAVIGATE_EVENT,
166
+ backdropClickClose: true,
167
+ });
168
+
169
+ return (
170
+ <dialog
171
+ ref={dialogRef}
172
+ onClick={handleBackdropClick}
173
+ className={IMAGE_ENLARGE_DIALOG_CLASS}
174
+ style={ENLARGE_DIALOG_STYLE}
175
+ >
176
+ {imgData && (
177
+ <>
178
+ <div className="relative">
179
+ <img
180
+ src={imgData.currentSrc || imgData.src}
181
+ srcSet={imgData.srcset}
182
+ sizes={imgData.srcset ? "85vw" : undefined}
183
+ alt={imgData.alt}
184
+ className="block max-h-[85vh] max-w-[85vw] object-contain"
185
+ />
186
+ </div>
187
+ <button
188
+ type="button"
189
+ onClick={() => dialogRef.current?.close()}
190
+ className="zd-enlarge-dialog-close"
191
+ aria-label="Close enlarged image"
192
+ >
193
+ <svg viewBox="0 0 161.03 161.03" fill="currentColor" aria-hidden="true" focusable="false">
194
+ <polygon points="161.03 10.27 150.76 0 80.51 70.24 10.27 0 0 10.27 70.24 80.51 0 150.76 10.27 161.03 80.51 90.78 150.76 161.03 161.03 150.76 90.78 80.51 161.03 10.27" />
195
+ </svg>
196
+ </button>
197
+ </>
198
+ )}
199
+ </dialog>
200
+ );
201
+ }
202
+ ImageEnlarge.displayName = "ImageEnlarge";
203
+
204
+ /**
205
+ * Static SSR fallback for the {@link ImageEnlarge} island.
206
+ *
207
+ * Renders an empty, closed `<dialog class="zd-enlarge-dialog ...">` so the
208
+ * dist HTML carries the dialog shell even before hydration. Sources its
209
+ * class and inline style from the shared constants in island-types so the
210
+ * SSR fallback cannot drift from the hydrated dialog above.
211
+ */
212
+ export function ImageEnlargeSsrFallback() {
213
+ return (
214
+ <dialog
215
+ className={IMAGE_ENLARGE_DIALOG_CLASS}
216
+ style={ENLARGE_DIALOG_STYLE}
217
+ />
218
+ );
219
+ }
@@ -0,0 +1,165 @@
1
+ "use client";
2
+
3
+ /** @jsxRuntime automatic */
4
+ /** @jsxImportSource preact */
5
+ // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
6
+ // lets us consume React-typed components in this Preact app.
7
+ import { useState, useEffect } from "preact/hooks";
8
+ // After zudolab/zudo-doc#1335 the host components pull lifecycle event names
9
+ // from the v2 transitions module rather than hard-coding `astro:*` literals.
10
+ import { AFTER_NAVIGATE_EVENT } from "../transitions/index.js";
11
+ import { SidebarTree } from "../sidebar-tree-island/index.js";
12
+ import type { SidebarNavNode, SidebarRootMenuItem, SidebarLocaleLink } from "../sidebar/types.js";
13
+
14
+ const cx = (...classes: Array<string | false | null | undefined>) =>
15
+ classes.filter(Boolean).join(" ");
16
+
17
+ // Mobile drawer hosts the SidebarTree directly (rather than receiving it as
18
+ // JSX children) so the tree's data props ride across the SSR → hydrate
19
+ // boundary inside the Island marker's `data-props` attribute. zfb's
20
+ // `Island()` only serialises a child component's *own* props (excluding
21
+ // `children`); when SidebarTree was passed as `children`, its data was
22
+ // dropped during hydration and Preact wiped the SSR-rendered tree DOM.
23
+ // Mirroring the desktop `<Sidebar treeComponent={SidebarTree} ...>` shape
24
+ // keeps the data attached to the wrapping island. zudolab/zudo-doc#1355
25
+ // wave 13.5.
26
+
27
+ export interface SidebarToggleProps {
28
+ nodes: SidebarNavNode[];
29
+ currentSlug?: string;
30
+ rootMenuItems?: SidebarRootMenuItem[];
31
+ backToMenuLabel?: string;
32
+ localeLinks?: SidebarLocaleLink[];
33
+ themeDefaultMode?: "light" | "dark";
34
+ }
35
+
36
+ export function SidebarToggle({
37
+ nodes,
38
+ currentSlug,
39
+ rootMenuItems,
40
+ backToMenuLabel,
41
+ localeLinks,
42
+ themeDefaultMode,
43
+ }: SidebarToggleProps) {
44
+ // Initial state must match SSR (`open=false`) so the hydration DOM
45
+ // matches the SSG output byte-for-byte. The backdrop and toggle-icon
46
+ // are rendered unconditionally below so the hydration tree has the
47
+ // same shape regardless of `open`, preventing Preact from re-mounting
48
+ // the subtree (which can drop click handlers on the hamburger button).
49
+ const [open, setOpen] = useState(false);
50
+
51
+ useEffect(() => {
52
+ if (open) {
53
+ document.body.style.overflow = "hidden";
54
+ } else {
55
+ document.body.style.overflow = "";
56
+ }
57
+ return () => {
58
+ document.body.style.overflow = "";
59
+ };
60
+ }, [open]);
61
+
62
+ // Close mobile sidebar on View Transition navigation
63
+ useEffect(() => {
64
+ function handleSwap() {
65
+ setOpen(false);
66
+ }
67
+ document.addEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
68
+ return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
69
+ }, []);
70
+
71
+ return (
72
+ <>
73
+ {/* Hamburger button - visible only on mobile.
74
+ Both icons are always rendered so the SSR output has the same
75
+ DOM shape as the post-hydration tree. The closed-state icon is
76
+ hidden via `hidden` when open=true, and vice versa, so Preact's
77
+ hydration walk sees byte-stable markup and keeps the click
78
+ handler attached. */}
79
+ <button
80
+ type="button"
81
+ onClick={() => setOpen(!open)}
82
+ className="lg:hidden px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg"
83
+ aria-label={open ? "Close sidebar" : "Open sidebar"}
84
+ aria-expanded={open}
85
+ >
86
+ {/* X icon — visible only when open */}
87
+ <svg
88
+ xmlns="http://www.w3.org/2000/svg"
89
+ className={cx("h-icon-lg w-icon-lg", !open && "hidden")}
90
+ aria-hidden="true"
91
+ fill="none"
92
+ viewBox="0 0 24 24"
93
+ stroke="currentColor"
94
+ strokeWidth={2}
95
+ >
96
+ <path
97
+ strokeLinecap="round"
98
+ strokeLinejoin="round"
99
+ d="M6 18L18 6M6 6l12 12"
100
+ />
101
+ </svg>
102
+ {/* Hamburger icon — visible only when closed */}
103
+ <svg
104
+ xmlns="http://www.w3.org/2000/svg"
105
+ className={cx("h-icon-lg w-icon-lg", open && "hidden")}
106
+ aria-hidden="true"
107
+ fill="none"
108
+ viewBox="0 0 24 24"
109
+ stroke="currentColor"
110
+ strokeWidth={2}
111
+ >
112
+ <path
113
+ strokeLinecap="round"
114
+ strokeLinejoin="round"
115
+ d="M4 6h16M4 12h16M4 18h16"
116
+ />
117
+ </svg>
118
+ </button>
119
+
120
+ {/* Backdrop overlay - mobile only.
121
+ Rendered unconditionally; CSS `hidden` toggles visibility so
122
+ the SSR DOM tree matches the hydrated tree (no subtree
123
+ mount/unmount across the hydration boundary).
124
+ `z-modal-backdrop` (50) intentionally sits ABOVE the header
125
+ (`z-toolbar`, 20): the open mobile drawer is a modal surface that
126
+ dims the whole viewport, header included. Closing is via tapping the
127
+ backdrop (onClick below), so the header hamburger being dimmed under
128
+ it is fine. */}
129
+ <div
130
+ className={cx("fixed inset-0 z-modal-backdrop bg-overlay/30 lg:hidden", !open && "hidden")}
131
+ aria-hidden={!open}
132
+ onClick={() => setOpen(false)}
133
+ />
134
+
135
+ {/* Sidebar panel - mobile only (desktop sidebar is in doc-layout).
136
+ `inert` when closed: the panel is only visually hidden via
137
+ `-translate-x-full`, so without `inert` its links/filter/buttons
138
+ stay in the tab order and accessibility tree while off-screen
139
+ (zudolab/zudo-doc#2059). `inert={false}` serialises to no attribute,
140
+ so the SSR (open=false → `inert`) and initial client render stay
141
+ byte-stable for hydration. */}
142
+ <aside
143
+ inert={!open}
144
+ className={`
145
+ fixed top-[3.5rem] left-0 z-modal h-[calc(100vh-3.5rem)] w-[16rem] flex flex-col
146
+ border-r border-muted bg-bg transition-transform duration-200
147
+ lg:hidden
148
+ ${open ? "translate-x-0" : "-translate-x-full"}
149
+ `}
150
+ >
151
+ <div className="flex-1 overflow-y-auto">
152
+ <SidebarTree
153
+ nodes={nodes}
154
+ currentSlug={currentSlug}
155
+ rootMenuItems={rootMenuItems}
156
+ backToMenuLabel={backToMenuLabel}
157
+ localeLinks={localeLinks}
158
+ themeDefaultMode={themeDefaultMode}
159
+ />
160
+ </div>
161
+ </aside>
162
+ </>
163
+ );
164
+ }
165
+ SidebarToggle.displayName = "SidebarToggle";