@dogsbay/docs-layout 0.2.0-beta.92 → 0.2.0-beta.93

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/docs-layout",
3
- "version": "0.2.0-beta.92",
3
+ "version": "0.2.0-beta.93",
4
4
  "description": "Standard documentation layout components for Dogsbay",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,10 +29,11 @@
29
29
  "./json-ld": "./src/json-ld.ts"
30
30
  },
31
31
  "dependencies": {
32
- "@dogsbay/ui": "0.2.0-beta.92",
33
- "@dogsbay/primitives": "0.2.0-beta.92"
32
+ "@dogsbay/ui": "0.2.0-beta.93",
33
+ "@dogsbay/primitives": "0.2.0-beta.93"
34
34
  },
35
35
  "devDependencies": {
36
+ "happy-dom": "^20.8.9",
36
37
  "vitest": "^3.0.0"
37
38
  },
38
39
  "peerDependencies": {
@@ -37,6 +37,20 @@ const {
37
37
  llmsLinkHref = "/llms.txt",
38
38
  class: className,
39
39
  } = Astro.props;
40
+
41
+ // `lastUpdated` is a canonical ISO string (e.g. "2026-06-16T00:00:00.000Z").
42
+ // Render it as a clean, locale-stable date; UTC avoids an off-by-one when the
43
+ // build host isn't on UTC. Non-date strings (e.g. a version/tag) pass through.
44
+ function formatDate(value: string): string {
45
+ const d = new Date(value);
46
+ if (Number.isNaN(d.getTime())) return value;
47
+ return d.toLocaleDateString("en-US", {
48
+ year: "numeric",
49
+ month: "long",
50
+ day: "numeric",
51
+ timeZone: "UTC",
52
+ });
53
+ }
40
54
  ---
41
55
 
42
56
  <footer class:list={["mt-12 border-t pt-6", className]}>
@@ -49,7 +63,7 @@ const {
49
63
  Edit this page
50
64
  </a>
51
65
  )}
52
- {lastUpdated && <span>Last updated: {lastUpdated}</span>}
66
+ {lastUpdated && <span>Last updated: {formatDate(lastUpdated)}</span>}
53
67
  </div>
54
68
  )}
55
69
 
@@ -64,7 +64,7 @@ function hasActiveDescendant(item: NavItem, current: string): boolean {
64
64
  * sync. Padding is computed from `level` the same way (`8 + level*12`
65
65
  * pixels) so indentation lines up across the same render.
66
66
  */
67
- function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
67
+ export function renderItem(item: NavItem, current: string, level: number): HTMLLIElement {
68
68
  const li = document.createElement("li");
69
69
  li.dataset.sidebar = "nav-tree-item";
70
70
 
@@ -73,7 +73,79 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
73
73
  const padLeft = `${8 + level * 12}px`;
74
74
  const heightClass = level === 0 ? "h-8" : "h-7";
75
75
 
76
- if (hasChildren) {
76
+ if (hasChildren && item.href) {
77
+ // A branch that is ALSO a page (section landing page) uses the APG
78
+ // "disclosure navigation" pattern: a real link (navigates) and a
79
+ // SEPARATE toggle <button> (expands/collapses), as siblings. Nesting
80
+ // a focusable <a> inside the interactive <summary> — as this did
81
+ // before — is an axe `nested-interactive` violation ("interactive
82
+ // controls must not be nested"). Native <details> can't hold a
83
+ // visible-when-collapsed header link without that nesting, so a
84
+ // page-branch drops <details> for link + button + sibling submenu.
85
+ const open = active || hasActiveDescendant(item, current);
86
+ const submenuId = `nav-sub-${item.href.replace(/[^a-z0-9]+/gi, "-")}-${level}`;
87
+
88
+ const row = document.createElement("div");
89
+ row.className = [
90
+ "flex w-full min-w-0 items-center gap-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
91
+ heightClass,
92
+ active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
93
+ ]
94
+ .filter(Boolean)
95
+ .join(" ");
96
+ row.style.paddingLeft = padLeft;
97
+
98
+ const toggle = document.createElement("button");
99
+ toggle.type = "button";
100
+ toggle.dataset.navToggle = "";
101
+ toggle.setAttribute("aria-controls", submenuId);
102
+ toggle.setAttribute("aria-expanded", String(open));
103
+ toggle.setAttribute("aria-label", `Toggle ${item.label} section`);
104
+ toggle.className =
105
+ "shrink-0 rounded outline-none ring-sidebar-ring focus-visible:ring-2";
106
+ toggle.innerHTML =
107
+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
108
+ row.appendChild(toggle);
109
+
110
+ if (item.icon) {
111
+ const iconSpan = document.createElement("span");
112
+ iconSpan.className = "shrink-0 [&>svg]:size-4";
113
+ iconSpan.innerHTML = item.icon;
114
+ row.appendChild(iconSpan);
115
+ }
116
+
117
+ const link = document.createElement("a");
118
+ link.className =
119
+ "min-w-0 flex-1 truncate text-inherit no-underline outline-none ring-sidebar-ring focus-visible:ring-2";
120
+ link.href = item.href;
121
+ link.textContent = item.label;
122
+ link.dataset.navHref = item.href; // rehighlight keys off this
123
+ if (active) link.dataset.active = "true";
124
+ row.appendChild(link);
125
+
126
+ const submenu = document.createElement("ul");
127
+ submenu.id = submenuId;
128
+ submenu.dataset.navSubmenu = "";
129
+ submenu.className = "flex min-w-0 flex-col";
130
+ submenu.dataset.sidebar = "nav-tree";
131
+ submenu.dataset.level = String(level + 1);
132
+ for (const child of item.children!) {
133
+ submenu.appendChild(renderItem(child, current, level + 1));
134
+ }
135
+ if (!open) submenu.hidden = true;
136
+
137
+ toggle.addEventListener("click", () => {
138
+ const isOpen = toggle.getAttribute("aria-expanded") === "true";
139
+ toggle.setAttribute("aria-expanded", String(!isOpen));
140
+ submenu.hidden = isOpen;
141
+ });
142
+
143
+ li.appendChild(row);
144
+ li.appendChild(submenu);
145
+ } else if (hasChildren) {
146
+ // Pure grouping label (no href): native <details>/<summary> is fully
147
+ // accessible here — the summary is the only interactive control, and
148
+ // its label is a plain <span>. Zero-JS expand/collapse.
77
149
  const details = document.createElement("details");
78
150
  if (active || hasActiveDescendant(item, current)) details.open = true;
79
151
 
@@ -81,16 +153,11 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
81
153
  summary.className = [
82
154
  "flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-sm text-sidebar-foreground outline-none [list-style:none] ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&::-webkit-details-marker]:hidden",
83
155
  heightClass,
84
- active ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : "",
85
156
  ]
86
157
  .filter(Boolean)
87
158
  .join(" ");
88
159
  summary.style.paddingLeft = padLeft;
89
- if (item.href) summary.dataset.navHref = item.href;
90
- if (active) summary.dataset.active = "true";
91
160
 
92
- // Chevron SVG — matches SidebarNavTree's rotation-on-open via CSS
93
- // (`details[open] > summary [data-chevron] { transform: rotate(90deg); }`).
94
161
  summary.innerHTML =
95
162
  '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 transition-transform duration-200" data-chevron aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
96
163
 
@@ -102,13 +169,12 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
102
169
  }
103
170
 
104
171
  const label = document.createElement("span");
105
- label.className = "truncate";
172
+ label.className = "min-w-0 flex-1 truncate";
106
173
  label.textContent = item.label;
107
174
  summary.appendChild(label);
108
175
 
109
176
  details.appendChild(summary);
110
177
 
111
- // Recurse — nested `<ul>` mirrors SidebarNavTree's `<Astro.self>`.
112
178
  const childUl = document.createElement("ul");
113
179
  childUl.className = "flex min-w-0 flex-col";
114
180
  childUl.dataset.sidebar = "nav-tree";
@@ -156,7 +222,7 @@ function renderItem(item: NavItem, current: string, level: number): HTMLLIElemen
156
222
  return li;
157
223
  }
158
224
 
159
- function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
225
+ export function renderTree(items: NavItem[], current: string, root: HTMLElement): void {
160
226
  const ul = document.createElement("ul");
161
227
  ul.className =
162
228
  "flex min-w-0 flex-col w-full group-data-[collapsible=icon]:hidden";
@@ -199,7 +265,9 @@ function rehighlight(root: HTMLElement, current: string): void {
199
265
  el.classList.remove(...ACTIVE_CLASSES);
200
266
  }
201
267
  }
202
- // Expand ancestors of the new active item.
268
+ // Expand ancestors of the new active item — both native <details>
269
+ // groups and the button-driven [data-nav-submenu] disclosures used by
270
+ // section-landing (href) branches.
203
271
  const active = root.querySelector<HTMLElement>('[data-active="true"]');
204
272
  if (active) {
205
273
  let parent: HTMLElement | null = active.parentElement;
@@ -207,6 +275,13 @@ function rehighlight(root: HTMLElement, current: string): void {
207
275
  if (parent.tagName === "DETAILS") {
208
276
  (parent as HTMLDetailsElement).open = true;
209
277
  }
278
+ if (parent.matches("[data-nav-submenu]")) {
279
+ parent.hidden = false;
280
+ const toggle = root.querySelector<HTMLElement>(
281
+ `[data-nav-toggle][aria-controls="${parent.id}"]`,
282
+ );
283
+ toggle?.setAttribute("aria-expanded", "true");
284
+ }
210
285
  parent = parent.parentElement;
211
286
  }
212
287
  }