@takazudo/zudo-doc 5.26.2 → 5.26.4

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 CHANGED
@@ -8,6 +8,23 @@ The format is based on Keep a Changelog, and release notes are generated from th
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## [5.26.4] - 2026-09-22
12
+
13
+ ### Bug Fixes
14
+
15
+ - The mobile sidebar drawer now closes when Escape is pressed, and focus returns to the toggle button. The drawer is a modal-style surface — it renders above a full-viewport backdrop, locks body scroll while open, and is `inert` while closed — so Escape is the dismissal that keyboard-only and assistive-technology users reach for first, and it previously did nothing at all: the `SidebarToggle` island registered no key handler. The listener is now attached to `document` only while the drawer is open, so a closed drawer never swallows an Escape meant for another surface such as the language switcher or the theme-pack flyout. Focus is restored to the toggle as the drawer closes; because the panel becomes `inert` again, focus left inside it would otherwise strand on `<body>`. The handler ignores an Escape that ends an IME composition, so cancelling a Japanese conversion in the drawer's own filter field no longer dismisses the drawer. (10036db43, 4445f1972)
16
+ - The toggle's X now closes the drawer when clicked. While the drawer was open the toggle rendered the close icon but sat underneath the backdrop — `z-modal-backdrop` (50) covers the header's `z-toolbar` (20), so `document.elementFromPoint()` at the button's own centre returned the backdrop element and every real pointer event was intercepted. The toggle now joins the drawer's own tier (`relative z-modal`, 60) for exactly as long as the drawer is open, so the X dismisses the drawer as its icon implies; the rest of the header stays dimmed and non-interactive underneath, and the closed-state markup is unchanged. Together with the Escape fix above, this restores both the keyboard and the pointer dismissal — before the two, clicking the backdrop was the only working way out of an open drawer, and nothing in the interface advertised it. (ffe0f728b)
17
+
18
+ ## [5.26.3] - 2026-09-21
19
+
20
+ ### Bug Fixes
21
+
22
+ - The mobile sidebar toggle no longer renders both of its icons at once in a project whose global CSS contains an unlayered media reset such as `img, svg, video, canvas, audio, iframe, embed, object { display: block }`. The inactive icon used to be hidden with Tailwind's `.hidden` utility, which ships inside `@layer utilities` — an unlayered author rule outranks every layered rule by cascade-layer precedence regardless of source order, so both icons computed to `display: block`, stacked vertically, and made the toggle a 48px control showing the X and the hamburger together. The inactive icon is now hidden with an inline declaration, which no consumer stylesheet can outrank. (30d6625e8)
23
+
24
+ ### Other Changes
25
+
26
+ - The `@takazudo/zfb`, `@takazudo/zfb-runtime`, and `@takazudo/zfb-md-wasm` peer dependency floors are now `^2.20.1`. The 2.20.1 release is lockstep metadata only — no upstream package reports a package-specific change, and the published-tarball delta is limited to each package's own version field plus its platform-binary pins. No zudo-doc config migration is required. (07d700356)
27
+
11
28
  ## [5.26.2] - 2026-09-20
12
29
 
13
30
  ### Bug Fixes
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "preact/jsx-runtime";
3
- import { useState, useEffect } from "preact/hooks";
3
+ import { useState, useEffect, useRef } from "preact/hooks";
4
4
  import { AFTER_NAVIGATE_EVENT, ensureNestedIslandPropsRefresh } from "../transitions/index.js";
5
5
  import { SidebarTree } from "../sidebar-tree-island/index.js";
6
6
  ensureNestedIslandPropsRefresh();
7
7
  const cx = (...classes) => classes.filter(Boolean).join(" ");
8
+ const HIDDEN_ICON_STYLE = "display:none";
8
9
  function SidebarToggle({
9
10
  nodes,
10
11
  currentSlug,
@@ -16,6 +17,7 @@ function SidebarToggle({
16
17
  dateFormats
17
18
  }) {
18
19
  const [open, setOpen] = useState(false);
20
+ const hamburgerRef = useRef(null);
19
21
  useEffect(() => {
20
22
  if (open) {
21
23
  document.body.style.overflow = "hidden";
@@ -33,13 +35,39 @@ function SidebarToggle({
33
35
  document.addEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
34
36
  return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
35
37
  }, []);
38
+ useEffect(() => {
39
+ if (!open) return;
40
+ function handleKeyDown(event) {
41
+ if (event.isComposing) return;
42
+ if (event.key === "Escape") {
43
+ setOpen(false);
44
+ hamburgerRef.current?.focus();
45
+ }
46
+ }
47
+ document.addEventListener("keydown", handleKeyDown);
48
+ return () => document.removeEventListener("keydown", handleKeyDown);
49
+ }, [open]);
36
50
  return /* @__PURE__ */ jsxs(Fragment, { children: [
37
51
  /* @__PURE__ */ jsxs(
38
52
  "button",
39
53
  {
54
+ ref: hamburgerRef,
40
55
  type: "button",
41
56
  onClick: () => setOpen(!open),
42
- className: "lg:hidden shrink-0 px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg",
57
+ className: cx(
58
+ "lg:hidden shrink-0 px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg",
59
+ // While open, the button IS the close control (it shows the X), so it
60
+ // has to sit in the drawer's own tier rather than under the backdrop —
61
+ // `z-modal-backdrop` (50) otherwise intercepts every pointer event at
62
+ // the button's own centre and the X is unclickable
63
+ // (zudolab/zudo-doc#4369). `relative` is required: a bare `z-index`
64
+ // has no effect on a statically-positioned element.
65
+ // Conditioning on `open` is load-bearing, not cosmetic: SSR always
66
+ // renders `open=false`, so the closed-state markup stays
67
+ // byte-identical (hydration-stable, and the A2 no-stub parity hashes
68
+ // for this directory do not move).
69
+ open && "relative z-modal"
70
+ ),
43
71
  "aria-label": open ? "Close sidebar" : "Open sidebar",
44
72
  "aria-expanded": open,
45
73
  children: [
@@ -47,7 +75,8 @@ function SidebarToggle({
47
75
  "svg",
48
76
  {
49
77
  xmlns: "http://www.w3.org/2000/svg",
50
- className: cx("h-icon-lg w-icon-lg", !open && "hidden"),
78
+ className: "h-icon-lg w-icon-lg",
79
+ style: open ? void 0 : HIDDEN_ICON_STYLE,
51
80
  "aria-hidden": "true",
52
81
  fill: "none",
53
82
  viewBox: "0 0 24 24",
@@ -67,7 +96,8 @@ function SidebarToggle({
67
96
  "svg",
68
97
  {
69
98
  xmlns: "http://www.w3.org/2000/svg",
70
- className: cx("h-icon-lg w-icon-lg", open && "hidden"),
99
+ className: "h-icon-lg w-icon-lg",
100
+ style: open ? HIDDEN_ICON_STYLE : void 0,
71
101
  "aria-hidden": "true",
72
102
  fill: "none",
73
103
  viewBox: "0 0 24 24",
@@ -4,7 +4,7 @@
4
4
  /** @jsxImportSource preact */
5
5
  // Use preact hook entrypoints directly — the "react" → "preact/compat" alias
6
6
  // lets us consume React-typed components in this Preact app.
7
- import { useState, useEffect } from "preact/hooks";
7
+ import { useState, useEffect, useRef } from "preact/hooks";
8
8
  // After zudolab/zudo-doc#1335 the host components pull lifecycle event names
9
9
  // from the v2 transitions module rather than hard-coding `astro:*` literals.
10
10
  // `ensureNestedIslandPropsRefresh` is imported through the barrel (not the
@@ -27,6 +27,17 @@ ensureNestedIslandPropsRefresh();
27
27
  const cx = (...classes: Array<string | false | null | undefined>) =>
28
28
  classes.filter(Boolean).join(" ");
29
29
 
30
+ // Icon visibility deliberately does NOT ride on Tailwind's `.hidden` utility.
31
+ // `.hidden` is emitted inside `@layer utilities`, and a consumer component pack
32
+ // that ships an UNLAYERED media reset — `img, svg, video, … { display: block }`
33
+ // — outranks every layered rule by cascade-layer precedence, no matter the
34
+ // source order. Both icons then computed to `display: block`, stacked, and made
35
+ // the mobile toggle 48px tall (zudolab/zudo-doc#4355). An inline declaration
36
+ // sits above all author rules, layered or not, so the state holds against any
37
+ // consumer stylesheet. It is a plain string so SSR and the initial client
38
+ // render serialise byte-identically for hydration.
39
+ const HIDDEN_ICON_STYLE = "display:none";
40
+
30
41
  // Mobile drawer hosts the SidebarTree directly (rather than receiving it as
31
42
  // JSX children) so the tree's data props ride across the SSR → hydrate
32
43
  // boundary inside the Island marker's `data-props` attribute. zfb's
@@ -76,6 +87,9 @@ export function SidebarToggle({
76
87
  // same shape regardless of `open`, preventing Preact from re-mounting
77
88
  // the subtree (which can drop click handlers on the hamburger button).
78
89
  const [open, setOpen] = useState(false);
90
+ // Focus-restore target for the Escape handler below. A `ref` does not
91
+ // serialise, so SSR/hydration markup is unaffected (zudolab/zudo-doc#4366).
92
+ const hamburgerRef = useRef<HTMLButtonElement>(null);
79
93
 
80
94
  useEffect(() => {
81
95
  if (open) {
@@ -97,25 +111,69 @@ export function SidebarToggle({
97
111
  return () => document.removeEventListener(AFTER_NAVIGATE_EVENT, handleSwap);
98
112
  }, []);
99
113
 
114
+ // Escape-to-close (zudolab/zudo-doc#4366). Deliberately inlined rather than
115
+ // reusing `connectEscapeToClose` from theme-pack-switcher/switcher-state.js
116
+ // — this island is ejectable and eject's `rewireImports` would rewrite that
117
+ // relative import to `@takazudo/zudo-doc/theme-pack-switcher`, a subpath
118
+ // absent from the package's `exports` map, shipping a broken ejected copy.
119
+ // The listener is registered only while `open` is true: a closed drawer
120
+ // must never swallow an Escape meant for another document-level listener
121
+ // (language switcher, find bar, theme-pack flyout). Focus is restored to
122
+ // the hamburger synchronously in the handler — the `<aside>` goes `inert`
123
+ // on close, so without this, focus left inside the drawer would strand on
124
+ // `<body>` and defeat the point of the fix for keyboard/AT users.
125
+ useEffect(() => {
126
+ if (!open) return;
127
+ function handleKeyDown(event: KeyboardEvent) {
128
+ // An Escape that ends an IME composition belongs to the composition, not
129
+ // to the drawer: cancelling a Japanese conversion in the drawer's own
130
+ // "Filter navigation" input would otherwise dismiss the whole drawer and
131
+ // yank focus to the hamburger. Same guard the sibling document-level
132
+ // shortcut in `sidebar-tree-island/index.tsx` already uses.
133
+ if (event.isComposing) return;
134
+ if (event.key === "Escape") {
135
+ setOpen(false);
136
+ hamburgerRef.current?.focus();
137
+ }
138
+ }
139
+ document.addEventListener("keydown", handleKeyDown);
140
+ return () => document.removeEventListener("keydown", handleKeyDown);
141
+ }, [open]);
142
+
100
143
  return (
101
144
  <>
102
145
  {/* Hamburger button - visible only on mobile.
103
146
  Both icons are always rendered so the SSR output has the same
104
- DOM shape as the post-hydration tree. The closed-state icon is
105
- hidden via `hidden` when open=true, and vice versa, so Preact's
106
- hydration walk sees byte-stable markup and keeps the click
107
- handler attached. */}
147
+ DOM shape as the post-hydration tree. The inactive one is hidden
148
+ via an inline `display:none` (see HIDDEN_ICON_STYLE above), so
149
+ Preact's hydration walk sees byte-stable markup and keeps the
150
+ click handler attached. */}
108
151
  <button
152
+ ref={hamburgerRef}
109
153
  type="button"
110
154
  onClick={() => setOpen(!open)}
111
- className="lg:hidden shrink-0 px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg"
155
+ className={cx(
156
+ "lg:hidden shrink-0 px-hsp-sm py-vsp-xs -ml-hsp-sm mr-hsp-sm text-muted hover:text-fg",
157
+ // While open, the button IS the close control (it shows the X), so it
158
+ // has to sit in the drawer's own tier rather than under the backdrop —
159
+ // `z-modal-backdrop` (50) otherwise intercepts every pointer event at
160
+ // the button's own centre and the X is unclickable
161
+ // (zudolab/zudo-doc#4369). `relative` is required: a bare `z-index`
162
+ // has no effect on a statically-positioned element.
163
+ // Conditioning on `open` is load-bearing, not cosmetic: SSR always
164
+ // renders `open=false`, so the closed-state markup stays
165
+ // byte-identical (hydration-stable, and the A2 no-stub parity hashes
166
+ // for this directory do not move).
167
+ open && "relative z-modal",
168
+ )}
112
169
  aria-label={open ? "Close sidebar" : "Open sidebar"}
113
170
  aria-expanded={open}
114
171
  >
115
172
  {/* X icon — visible only when open */}
116
173
  <svg
117
174
  xmlns="http://www.w3.org/2000/svg"
118
- className={cx("h-icon-lg w-icon-lg", !open && "hidden")}
175
+ className="h-icon-lg w-icon-lg"
176
+ style={open ? undefined : HIDDEN_ICON_STYLE}
119
177
  aria-hidden="true"
120
178
  fill="none"
121
179
  viewBox="0 0 24 24"
@@ -131,7 +189,8 @@ export function SidebarToggle({
131
189
  {/* Hamburger icon — visible only when closed */}
132
190
  <svg
133
191
  xmlns="http://www.w3.org/2000/svg"
134
- className={cx("h-icon-lg w-icon-lg", open && "hidden")}
192
+ className="h-icon-lg w-icon-lg"
193
+ style={open ? HIDDEN_ICON_STYLE : undefined}
135
194
  aria-hidden="true"
136
195
  fill="none"
137
196
  viewBox="0 0 24 24"
@@ -152,9 +211,13 @@ export function SidebarToggle({
152
211
  mount/unmount across the hydration boundary).
153
212
  `z-modal-backdrop` (50) intentionally sits ABOVE the header
154
213
  (`z-toolbar`, 20): the open mobile drawer is a modal surface that
155
- dims the whole viewport, header included. Closing is via tapping the
156
- backdrop (onClick below), so the header hamburger being dimmed under
157
- it is fine. */}
214
+ dims the whole viewport, header included. The toggle button is the
215
+ one exception — while open it renders the X and advertises itself as
216
+ the close control, so it is lifted to `z-modal` (60) for exactly as
217
+ long as the drawer is open (see its className above). Everything else
218
+ in the header stays dimmed and non-interactive underneath.
219
+ Backdrop tapping (onClick below) remains a valid dismissal, as does
220
+ Escape (zudolab/zudo-doc#4366). */}
158
221
  <div
159
222
  className={cx("fixed inset-0 z-modal-backdrop bg-overlay/30 lg:hidden", !open && "hidden")}
160
223
  aria-hidden={!open}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/zudo-doc",
3
- "version": "5.26.2",
3
+ "version": "5.26.4",
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.0",
721
- "@takazudo/zfb-md-wasm": "^2.20.0",
722
- "@takazudo/zfb-runtime": "^2.20.0",
720
+ "@takazudo/zfb": "^2.20.1",
721
+ "@takazudo/zfb-md-wasm": "^2.20.1",
722
+ "@takazudo/zfb-runtime": "^2.20.1",
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.0",
761
- "@takazudo/zfb-md-wasm": "2.20.0",
762
- "@takazudo/zfb-runtime": "2.20.0",
760
+ "@takazudo/zfb": "2.20.1",
761
+ "@takazudo/zfb-md-wasm": "2.20.1",
762
+ "@takazudo/zfb-runtime": "2.20.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.2"
774
+ "@takazudo/zudo-doc-history-server": "5.26.4"
775
775
  },
776
776
  "scripts": {
777
777
  "gen:search-widget-script": "node scripts/gen-search-widget-script.mjs",