@pienter/ui 0.2.0 → 0.5.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/CONVENTIONS.md +297 -385
  3. package/README.md +3 -18
  4. package/components/feedback/toast/toast.css +1 -1
  5. package/components/form/combobox/Combobox.vue +21 -38
  6. package/components/form/combobox/combobox.css +1 -1
  7. package/components/form/form/Form.vue +1 -2
  8. package/components/form/label/label.css +1 -1
  9. package/components/form/number-field/NumberField.vue +1 -1
  10. package/components/form/number-field/number-field.css +1 -1
  11. package/components/form/radio-group/RadioGroup.vue +2 -5
  12. package/components/form/slider/slider.css +2 -3
  13. package/components/form/tags-input/tags-input.css +1 -2
  14. package/components/form/textarea/textarea.css +1 -1
  15. package/components/layout/accordion/Accordion.vue +6 -27
  16. package/components/layout/collapsible/Collapsible.vue +9 -19
  17. package/components/layout/table/table.css +3 -3
  18. package/components/navigation/pagination/Pagination.vue +1 -1
  19. package/components/navigation/pagination/PaginationFooter.vue +23 -0
  20. package/components/navigation/pagination/pagination.css +128 -29
  21. package/components/navigation/sidebar/Sidebar.vue +326 -84
  22. package/components/navigation/sidebar/SidebarMenuItem.vue +199 -0
  23. package/components/navigation/sidebar/context.ts +21 -0
  24. package/components/navigation/sidebar/sidebar.css +346 -78
  25. package/components/navigation/sidebar/types.ts +13 -1
  26. package/components/overlay/alert-dialog/AlertDialog.vue +10 -31
  27. package/components/overlay/command/Command.vue +10 -38
  28. package/components/overlay/command/command.css +1 -1
  29. package/components/overlay/dropdown-menu/DropdownMenu.vue +37 -62
  30. package/components/overlay/modal/Modal.vue +7 -28
  31. package/components/overlay/popover/Popover.vue +9 -31
  32. package/components/overlay/sheet/Sheet.vue +7 -28
  33. package/components/overlay/tooltip/Tooltip.vue +14 -19
  34. package/{utils/controllers/dialog.ts → composables/useDialog.ts} +90 -78
  35. package/composables/useDisclosure.ts +113 -0
  36. package/composables/useEventListener.ts +16 -0
  37. package/{utils/controllers/menu.ts → composables/useMenu.ts} +66 -102
  38. package/{utils/controllers/popover.ts → composables/usePopover.ts} +107 -120
  39. package/package.json +6 -38
  40. package/styles/0-settings/colors.css +8 -1
  41. package/styles/0-settings/layout.css +18 -0
  42. package/styles/0-settings/motion.css +2 -2
  43. package/styles/0-settings/spacing.css +3 -1
  44. package/utils/navigation/sidebar.ts +97 -0
  45. package/utils/validation/form.ts +6 -9
  46. package/components/action/button/Button.astro +0 -95
  47. package/components/action/button/IconButton.astro +0 -86
  48. package/components/display/avatar/Avatar.astro +0 -17
  49. package/components/display/avatar/AvatarStack.astro +0 -9
  50. package/components/display/badge/Badge.astro +0 -15
  51. package/components/display/empty/Empty.astro +0 -9
  52. package/components/display/icon/Icon.astro +0 -52
  53. package/components/feedback/alert/Alert.astro +0 -52
  54. package/components/feedback/progress/Progress.astro +0 -68
  55. package/components/feedback/skeleton/Skeleton.astro +0 -32
  56. package/components/feedback/spinner/Spinner.astro +0 -25
  57. package/components/feedback/toast/Toast.astro +0 -50
  58. package/components/form/checkbox/Checkbox.astro +0 -79
  59. package/components/form/date-input/DateInput.astro +0 -105
  60. package/components/form/form/Form.astro +0 -106
  61. package/components/form/input-otp/InputOTP.astro +0 -147
  62. package/components/form/label/Label.astro +0 -13
  63. package/components/form/number-field/NumberField.astro +0 -142
  64. package/components/form/radio-group/RadioGroup.astro +0 -105
  65. package/components/form/select/Select.astro +0 -105
  66. package/components/form/slider/Slider.astro +0 -205
  67. package/components/form/switch/Switch.astro +0 -75
  68. package/components/form/tags-input/TagsInput.astro +0 -153
  69. package/components/form/text-input/TextInput.astro +0 -84
  70. package/components/form/textarea/Textarea.astro +0 -86
  71. package/components/layout/card/Card.astro +0 -13
  72. package/components/layout/separator/Separator.astro +0 -31
  73. package/components/layout/table/Table.astro +0 -116
  74. package/components/navigation/breadcrumb/Breadcrumb.astro +0 -36
  75. package/components/navigation/navbar/Navbar.astro +0 -62
  76. package/components/navigation/sidebar/Sidebar.astro +0 -132
  77. package/components/navigation/sidebar/SidebarItemRender.astro +0 -83
  78. package/components/navigation/sidebar/SidebarItemRender.vue +0 -98
  79. package/components/overlay/alert-dialog/AlertDialog.astro +0 -112
  80. package/components/overlay/modal/Modal.astro +0 -66
  81. package/utils/controllers/disclosure.ts +0 -117
  82. package/utils/controllers/form.ts +0 -524
  83. package/utils/controllers/index.ts +0 -39
  84. package/utils/controllers/number-field.ts +0 -103
  85. package/utils/controllers/otp.ts +0 -252
  86. package/utils/controllers/sidebar.ts +0 -610
  87. package/utils/controllers/slider.ts +0 -336
  88. package/utils/controllers/tags-input.ts +0 -255
  89. package/utils/controllers/toast.ts +0 -426
@@ -1,610 +0,0 @@
1
- import { createFocusTrap, type FocusTrap } from '../a11y/focus.js';
2
- import { lockScroll } from '../dom/scroll-lock.js';
3
-
4
- /**
5
- * Imperative control surface returned by `mountSidebar`.
6
- *
7
- * Three independent state-machines composed inside one controller:
8
- * 1. Desktop collapse/expand — narrows the panel to icon-only width.
9
- * 2. Mobile drawer open/closed — overlay slide-in with focus trap +
10
- * body-scroll lock; only meaningful below `drawerBreakpoint`.
11
- * 3. Per-group expand/collapse — each group identified by a stable
12
- * string key; multiple groups may be expanded at once.
13
- *
14
- * Plus: roving-tabindex keyboard navigation across menu items.
15
- *
16
- * The controller is the source of truth for runtime side-effects
17
- * (DOM attributes, focus, scroll lock, listeners). The framework
18
- * (Vue prop / Astro consumer script) is the trigger source — it
19
- * calls `collapse()` / `openDrawer()` / `expandGroup()` and reflects
20
- * controller-emitted callbacks back into reactive state.
21
- */
22
- export interface SidebarControl {
23
- // Desktop collapse state.
24
- collapse(): void;
25
- expand(): void;
26
- isCollapsed(): boolean;
27
-
28
- // Mobile drawer state.
29
- openDrawer(): void;
30
- closeDrawer(): void;
31
- isDrawerOpen(): boolean;
32
-
33
- // Per-group expand/collapse.
34
- expandGroup(groupKey: string): void;
35
- collapseGroup(groupKey: string): void;
36
- isGroupExpanded(groupKey: string): boolean;
37
-
38
- /** Re-derive `aria-current="page"` for every link from the new active href. */
39
- setActiveHref(href: string | null | undefined): void;
40
-
41
- /** Release listeners, focus trap, scroll lock, and stored references. */
42
- teardown(): void;
43
- }
44
-
45
- export interface SidebarConfig {
46
- /** Initial desktop collapsed state. Default `false`. */
47
- collapsed?: boolean;
48
- /** Initial mobile drawer state. Default `false`. */
49
- drawerOpen?: boolean;
50
- /**
51
- * Pixel breakpoint below which drawer mode applies. Above this
52
- * width, the sidebar is a persistent column and drawer state has no
53
- * visual effect (the controller still tracks it but
54
- * `[data-drawer-state]` is meaningful only with the mobile-drawer
55
- * CSS media query). Default `768`.
56
- */
57
- drawerBreakpoint?: number;
58
- /** Initial set of expanded group keys. */
59
- expandedGroups?: string[];
60
- /** Initial active href used to derive `aria-current="page"`. */
61
- activeHref?: string | null;
62
- /**
63
- * Called after the user toggles the desktop collapse via internal
64
- * affordances (none in v1 — collapse is consumer-driven only).
65
- * Reserved for symmetry with other change callbacks.
66
- */
67
- onCollapsedChange?: (collapsed: boolean) => void;
68
- /**
69
- * Called when the user requests drawer close (Escape, backdrop
70
- * click). Mirrors `mountDialog`'s `onClose` — controller does NOT
71
- * auto-close; the caller flips its reactive state and calls
72
- * `closeDrawer()`.
73
- */
74
- onDrawerOpenChange?: (open: boolean) => void;
75
- /** Called when a group's expanded state changes via internal affordance (toggle click, keyboard). */
76
- onGroupExpandedChange?: (groupKey: string, expanded: boolean) => void;
77
- }
78
-
79
- // DOM hooks — locked markup contract between controller and component templates.
80
- const PANEL_SELECTOR = '[data-sidebar-panel]';
81
- const BACKDROP_SELECTOR = '[data-sidebar-backdrop]';
82
- const MENU_ITEM_SELECTOR =
83
- '[data-sidebar-menu-item]:not([data-disabled="true"])';
84
- const GROUP_SELECTOR = '[data-sidebar-group]';
85
- const GROUP_TOGGLE_SELECTOR = '[data-sidebar-group-toggle]';
86
- const GROUP_ITEMS_SELECTOR = '[data-sidebar-group-items]';
87
- const LINK_SELECTOR = '[data-sidebar-link]';
88
-
89
- /**
90
- * Hierarchical-href match — locked algorithm. Returns true when
91
- * `itemHref` is the prefix of `activeHref` honoring path boundaries.
92
- *
93
- * - `/` (root) is special-cased to strict equality so it doesn't
94
- * greedily match every URL.
95
- * - Substring-only matches are rejected (`/admin/members-old` does
96
- * NOT match `/admin/members`).
97
- *
98
- * See CONVENTIONS.md § Active-href hierarchical-match algorithm.
99
- */
100
- export function isHrefActive(
101
- activeHref: string | null | undefined,
102
- itemHref: string,
103
- ): boolean {
104
- if (!activeHref) return false;
105
- if (itemHref === '/') return activeHref === '/';
106
- if (activeHref === itemHref) return true;
107
- return activeHref.startsWith(itemHref + '/');
108
- }
109
-
110
- export function mountSidebar(
111
- el: HTMLElement,
112
- config: SidebarConfig = {},
113
- ): SidebarControl {
114
- const onClose = config.onDrawerOpenChange;
115
- const onGroup = config.onGroupExpandedChange;
116
-
117
- // Internal state.
118
- let collapsedState = config.collapsed ?? false;
119
- let drawerOpenState = false; // start closed; if config.drawerOpen, open() is called below
120
- const expandedGroups = new Set<string>(config.expandedGroups ?? []);
121
- let activeHref = config.activeHref ?? null;
122
-
123
- // Drawer-only transient state.
124
- let trap: FocusTrap | null = null;
125
- let unlockScrollFn: (() => void) | null = null;
126
- let triggerEl: HTMLElement | null = null;
127
- // Whether the controller installed `aria-label` on the panel during
128
- // openDrawer(). Used in closeDrawer() so we only strip the attribute
129
- // we added — a consumer-supplied `aria-label` on the panel must not
130
- // be clobbered.
131
- let panelAriaLabelSetByController = false;
132
- // Tracks whether THIS controller added the `tabindex="-1"` fallback to
133
- // the panel (when no focusable menu item existed at open time). If we
134
- // set it, we own removing it on close so the attribute doesn't persist
135
- // across open/close cycles. Mirrors `dialog.ts`'s `tabindexWasSetByUs`.
136
- let panelTabindexWasSetByController = false;
137
- const panel = el.querySelector<HTMLElement>(PANEL_SELECTOR) ?? el;
138
-
139
- // ---------------------------------------------------------------------------
140
- // Initial DOM sync.
141
- // ---------------------------------------------------------------------------
142
-
143
- el.setAttribute('data-state', collapsedState ? 'collapsed' : 'expanded');
144
- el.setAttribute('data-drawer-state', 'closed');
145
-
146
- // Sync per-group state from config.expandedGroups + the markup-level
147
- // `data-default-open` hint that templates emit for groups whose item
148
- // declared `defaultOpen: true`. The Set wins when both are present.
149
- el.querySelectorAll<HTMLElement>(GROUP_SELECTOR).forEach((groupEl) => {
150
- const key = groupEl.dataset.groupKey ?? '';
151
- if (!key) return;
152
- const defaultOpen = groupEl.dataset.defaultOpen === 'true';
153
- if (defaultOpen && !config.expandedGroups) {
154
- // Only honor markup defaults when the consumer didn't pass an
155
- // explicit expandedGroups override.
156
- expandedGroups.add(key);
157
- }
158
- syncGroupAttrs(groupEl, key, expandedGroups.has(key));
159
- });
160
-
161
- // Wire active-href.
162
- applyActiveHref();
163
-
164
- // Wire roving-tabindex initial state.
165
- syncRovingTabindex();
166
-
167
- // ---------------------------------------------------------------------------
168
- // Listeners.
169
- // ---------------------------------------------------------------------------
170
-
171
- el.addEventListener('click', handleClick);
172
- el.addEventListener('keydown', handleKeydown);
173
- el.addEventListener('focusin', handleFocusIn);
174
-
175
- function handleClick(event: MouseEvent): void {
176
- const target = event.target;
177
- if (!(target instanceof Element)) return;
178
-
179
- // Backdrop click → close drawer.
180
- const backdrop = target.closest(BACKDROP_SELECTOR);
181
- if (backdrop && drawerOpenState) {
182
- onClose?.(false);
183
- return;
184
- }
185
-
186
- // Group toggle click → toggle expand state.
187
- const toggle = target.closest<HTMLElement>(GROUP_TOGGLE_SELECTOR);
188
- if (toggle) {
189
- const group = toggle.closest<HTMLElement>(GROUP_SELECTOR);
190
- if (!group) return;
191
- const key = group.dataset.groupKey ?? '';
192
- if (!key) return;
193
- const next = !expandedGroups.has(key);
194
- if (next) {
195
- expandedGroups.add(key);
196
- } else {
197
- expandedGroups.delete(key);
198
- }
199
- syncGroupAttrs(group, key, next);
200
- // After expand/collapse, the visible-item set changed; refresh
201
- // the roving tabindex so keyboard nav skips collapsed children.
202
- syncRovingTabindex();
203
- onGroup?.(key, next);
204
- }
205
- }
206
-
207
- function handleKeydown(event: KeyboardEvent): void {
208
- // Escape closes the drawer when open (only meaningful on mobile).
209
- if (event.key === 'Escape' && drawerOpenState) {
210
- event.preventDefault();
211
- onClose?.(false);
212
- return;
213
- }
214
-
215
- const target = event.target;
216
- if (!(target instanceof HTMLElement)) return;
217
- const item = target.closest<HTMLElement>(MENU_ITEM_SELECTOR);
218
- if (!item) return;
219
-
220
- switch (event.key) {
221
- case 'ArrowDown': {
222
- event.preventDefault();
223
- const items = visibleMenuItems();
224
- const idx = items.indexOf(item);
225
- const next = items[Math.min(idx + 1, items.length - 1)];
226
- if (next) focusItem(next);
227
- return;
228
- }
229
- case 'ArrowUp': {
230
- event.preventDefault();
231
- const items = visibleMenuItems();
232
- const idx = items.indexOf(item);
233
- const next = items[Math.max(idx - 1, 0)];
234
- if (next) focusItem(next);
235
- return;
236
- }
237
- case 'Home': {
238
- event.preventDefault();
239
- const items = visibleMenuItems();
240
- if (items[0]) focusItem(items[0]);
241
- return;
242
- }
243
- case 'End': {
244
- event.preventDefault();
245
- const items = visibleMenuItems();
246
- const last = items[items.length - 1];
247
- if (last) focusItem(last);
248
- return;
249
- }
250
- case 'ArrowRight': {
251
- // On a group toggle: if collapsed, expand; if already expanded,
252
- // move into first child item.
253
- if (item.matches(GROUP_TOGGLE_SELECTOR)) {
254
- event.preventDefault();
255
- const group = item.closest<HTMLElement>(GROUP_SELECTOR);
256
- if (!group) return;
257
- const key = group.dataset.groupKey ?? '';
258
- if (!expandedGroups.has(key)) {
259
- expandedGroups.add(key);
260
- syncGroupAttrs(group, key, true);
261
- syncRovingTabindex();
262
- onGroup?.(key, true);
263
- } else {
264
- const groupItems =
265
- group.querySelector<HTMLElement>(
266
- GROUP_ITEMS_SELECTOR,
267
- );
268
- const firstChild =
269
- groupItems?.querySelector<HTMLElement>(
270
- MENU_ITEM_SELECTOR,
271
- );
272
- if (firstChild) focusItem(firstChild);
273
- }
274
- }
275
- return;
276
- }
277
- case 'ArrowLeft': {
278
- // On a child of an expanded group: collapse parent group and
279
- // move focus to its toggle.
280
- const parentGroup =
281
- item.parentElement?.closest<HTMLElement>(GROUP_SELECTOR);
282
- if (parentGroup && !item.matches(GROUP_TOGGLE_SELECTOR)) {
283
- event.preventDefault();
284
- const key = parentGroup.dataset.groupKey ?? '';
285
- if (expandedGroups.has(key)) {
286
- expandedGroups.delete(key);
287
- syncGroupAttrs(parentGroup, key, false);
288
- syncRovingTabindex();
289
- onGroup?.(key, false);
290
- }
291
- const toggle = parentGroup.querySelector<HTMLElement>(
292
- GROUP_TOGGLE_SELECTOR,
293
- );
294
- if (toggle) focusItem(toggle);
295
- }
296
- return;
297
- }
298
- case ' ': // Space
299
- case 'Spacebar': {
300
- // Native button activation handles Space on the group toggle
301
- // already; for `<a>` links we forward Space to a click so links
302
- // feel button-like inside a menu.
303
- if (item.matches(LINK_SELECTOR)) {
304
- event.preventDefault();
305
- item.click();
306
- }
307
- return;
308
- }
309
- default:
310
- return;
311
- }
312
- }
313
-
314
- function handleFocusIn(event: FocusEvent): void {
315
- const target = event.target;
316
- if (!(target instanceof HTMLElement)) return;
317
- const item = target.closest<HTMLElement>(MENU_ITEM_SELECTOR);
318
- if (!item) return;
319
- // Promote the focused item to tabindex=0; demote the previous.
320
- const items = el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR);
321
- items.forEach((it) => {
322
- it.setAttribute('tabindex', it === item ? '0' : '-1');
323
- });
324
- }
325
-
326
- // ---------------------------------------------------------------------------
327
- // Public API.
328
- // ---------------------------------------------------------------------------
329
-
330
- function collapse(): void {
331
- if (collapsedState) return;
332
- collapsedState = true;
333
- el.setAttribute('data-state', 'collapsed');
334
- }
335
-
336
- function expand(): void {
337
- if (!collapsedState) return;
338
- collapsedState = false;
339
- el.setAttribute('data-state', 'expanded');
340
- }
341
-
342
- function isCollapsed(): boolean {
343
- return collapsedState;
344
- }
345
-
346
- function openDrawer(): void {
347
- if (drawerOpenState) return;
348
- drawerOpenState = true;
349
-
350
- // Capture trigger for restore-on-close.
351
- const active = document.activeElement;
352
- triggerEl =
353
- active instanceof HTMLElement &&
354
- active !== document.body &&
355
- !el.contains(active)
356
- ? active
357
- : null;
358
-
359
- el.setAttribute('data-drawer-state', 'open');
360
-
361
- // Promote the panel to a modal dialog while drawer is open.
362
- // ARIA requires `role="dialog"` to have an accessible name, so
363
- // mirror the wrapper `<nav>`'s `aria-label` onto the panel. If the
364
- // panel already has its own `aria-label` (consumer-supplied), leave
365
- // it alone — closeDrawer() only strips what we added.
366
- panel.setAttribute('role', 'dialog');
367
- panel.setAttribute('aria-modal', 'true');
368
- if (!panel.hasAttribute('aria-label')) {
369
- const wrapperLabel = el.getAttribute('aria-label') ?? 'Navigation';
370
- panel.setAttribute('aria-label', wrapperLabel);
371
- panelAriaLabelSetByController = true;
372
- }
373
-
374
- unlockScrollFn = lockScroll();
375
- trap = createFocusTrap(panel);
376
-
377
- // Move focus inside the panel — first menu item, deferred a frame
378
- // so any CSS slide-in transition doesn't race with focus.
379
- requestAnimationFrame(() => {
380
- if (!drawerOpenState) return;
381
- const first = visibleMenuItems()[0];
382
- if (first) {
383
- focusItem(first);
384
- } else {
385
- // Fall back to focusing the panel so screen readers announce
386
- // the drawer. The panel needs to be focusable; if it isn't,
387
- // give it tabindex=-1 transiently. Track that we set it so
388
- // closeDrawer() can remove it again — we don't want to leave a
389
- // stray `tabindex` on the panel across open/close cycles.
390
- if (!panel.hasAttribute('tabindex')) {
391
- panel.setAttribute('tabindex', '-1');
392
- panelTabindexWasSetByController = true;
393
- }
394
- panel.focus();
395
- }
396
- });
397
- }
398
-
399
- function closeDrawer(): void {
400
- if (!drawerOpenState) return;
401
- drawerOpenState = false;
402
-
403
- el.setAttribute('data-drawer-state', 'closed');
404
- panel.removeAttribute('role');
405
- panel.removeAttribute('aria-modal');
406
- if (panelAriaLabelSetByController) {
407
- panel.removeAttribute('aria-label');
408
- panelAriaLabelSetByController = false;
409
- }
410
- // If we added the transient `tabindex="-1"` focus fallback in
411
- // openDrawer(), remove it now so it doesn't persist across
412
- // open/close cycles.
413
- if (panelTabindexWasSetByController) {
414
- panel.removeAttribute('tabindex');
415
- panelTabindexWasSetByController = false;
416
- }
417
-
418
- trap?.release();
419
- trap = null;
420
-
421
- unlockScrollFn?.();
422
- unlockScrollFn = null;
423
-
424
- // Restore focus to the trigger that opened the drawer.
425
- const trigger = triggerEl;
426
- triggerEl = null;
427
- if (trigger && document.contains(trigger)) {
428
- requestAnimationFrame(() => trigger.focus());
429
- }
430
- }
431
-
432
- function isDrawerOpen(): boolean {
433
- return drawerOpenState;
434
- }
435
-
436
- function expandGroup(key: string): void {
437
- if (!key || expandedGroups.has(key)) return;
438
- expandedGroups.add(key);
439
- const group = findGroup(key);
440
- if (group) {
441
- syncGroupAttrs(group, key, true);
442
- syncRovingTabindex();
443
- }
444
- }
445
-
446
- function collapseGroup(key: string): void {
447
- if (!key || !expandedGroups.has(key)) return;
448
- expandedGroups.delete(key);
449
- const group = findGroup(key);
450
- if (group) {
451
- syncGroupAttrs(group, key, false);
452
- syncRovingTabindex();
453
- }
454
- }
455
-
456
- function isGroupExpanded(key: string): boolean {
457
- return expandedGroups.has(key);
458
- }
459
-
460
- function setActiveHref(href: string | null | undefined): void {
461
- activeHref = href ?? null;
462
- applyActiveHref();
463
- // Active item changed — re-roving-tabindex so the active item gets
464
- // tabindex=0 (or the first item if no active match).
465
- syncRovingTabindex();
466
- }
467
-
468
- function teardown(): void {
469
- if (drawerOpenState) closeDrawer();
470
- el.removeEventListener('click', handleClick);
471
- el.removeEventListener('keydown', handleKeydown);
472
- el.removeEventListener('focusin', handleFocusIn);
473
- }
474
-
475
- // ---------------------------------------------------------------------------
476
- // Internal helpers.
477
- // ---------------------------------------------------------------------------
478
-
479
- function findGroup(key: string): HTMLElement | null {
480
- return el.querySelector<HTMLElement>(
481
- `${GROUP_SELECTOR}[data-group-key="${cssEscape(key)}"]`,
482
- );
483
- }
484
-
485
- function syncGroupAttrs(
486
- group: HTMLElement,
487
- _key: string,
488
- expanded: boolean,
489
- ): void {
490
- group.setAttribute('data-state', expanded ? 'expanded' : 'collapsed');
491
- const toggle = group.querySelector<HTMLElement>(GROUP_TOGGLE_SELECTOR);
492
- if (toggle)
493
- toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
494
- const items = group.querySelector<HTMLElement>(GROUP_ITEMS_SELECTOR);
495
- if (items) items.hidden = !expanded;
496
- }
497
-
498
- function applyActiveHref(): void {
499
- const links = el.querySelectorAll<HTMLAnchorElement>(LINK_SELECTOR);
500
- links.forEach((link) => {
501
- const href = link.getAttribute('href') ?? '';
502
- const active = isHrefActive(activeHref, href);
503
- if (active) {
504
- link.setAttribute('aria-current', 'page');
505
- } else {
506
- link.removeAttribute('aria-current');
507
- }
508
- });
509
-
510
- // Group active-state — true when any descendant link is active.
511
- el.querySelectorAll<HTMLElement>(GROUP_SELECTOR).forEach((group) => {
512
- const hasActive = !!group.querySelector('[aria-current="page"]');
513
- if (hasActive) {
514
- group.setAttribute('data-active', 'true');
515
- } else {
516
- group.removeAttribute('data-active');
517
- }
518
- });
519
- }
520
-
521
- function visibleMenuItems(): HTMLElement[] {
522
- // Items that aren't disabled, aren't hidden by an ancestor with
523
- // `hidden`, and aren't inside a collapsed group.
524
- return Array.from(
525
- el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
526
- ).filter((item) => !isInsideCollapsedGroup(item));
527
- }
528
-
529
- function isInsideCollapsedGroup(item: HTMLElement): boolean {
530
- // Walk up looking for an ancestor `[data-sidebar-group-items]` that
531
- // is hidden — if found, the item is inside a collapsed group.
532
- let cur: HTMLElement | null = item.parentElement;
533
- while (cur && cur !== el) {
534
- if (cur.matches(GROUP_ITEMS_SELECTOR) && cur.hidden) return true;
535
- cur = cur.parentElement;
536
- }
537
- return false;
538
- }
539
-
540
- function syncRovingTabindex(): void {
541
- const items = Array.from(
542
- el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR),
543
- );
544
- if (items.length === 0) return;
545
- // Prefer the active item; fall back to the first visible item.
546
- const activeItem =
547
- items.find(
548
- (it) =>
549
- it.getAttribute('aria-current') === 'page' &&
550
- !isInsideCollapsedGroup(it),
551
- ) ??
552
- items.find((it) => !isInsideCollapsedGroup(it)) ??
553
- items[0];
554
- items.forEach((it) => {
555
- it.setAttribute('tabindex', it === activeItem ? '0' : '-1');
556
- });
557
- }
558
-
559
- function focusItem(item: HTMLElement): void {
560
- // Roving tabindex: promote the target before focusing so the
561
- // following focusin handler doesn't fight us.
562
- el.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR).forEach((it) => {
563
- it.setAttribute('tabindex', it === item ? '0' : '-1');
564
- });
565
- item.focus();
566
- }
567
-
568
- // -- public surface -- //
569
- return {
570
- collapse,
571
- expand,
572
- isCollapsed,
573
- openDrawer,
574
- closeDrawer,
575
- isDrawerOpen,
576
- expandGroup,
577
- collapseGroup,
578
- isGroupExpanded,
579
- setActiveHref,
580
- teardown,
581
- };
582
- }
583
-
584
- /**
585
- * Lightweight CSS.escape fallback for the few places we build attribute
586
- * selectors from group keys. Not exhaustive — group keys are
587
- * kebab-case slugs by convention so the common cases work; we still
588
- * escape quotes and backslashes for safety.
589
- */
590
- function cssEscape(value: string): string {
591
- if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
592
- return CSS.escape(value);
593
- }
594
- return value.replace(/["\\]/g, '\\$&');
595
- }
596
-
597
- /**
598
- * Public helper — kebab-case slug for deriving a group key from a
599
- * label when the consumer didn't supply an explicit `key`. Lowercases,
600
- * strips diacritics, replaces non-alphanumerics with `-`, and trims
601
- * leading/trailing dashes.
602
- */
603
- export function deriveGroupKey(label: string): string {
604
- return label
605
- .normalize('NFKD')
606
- .replace(/[̀-ͯ]/g, '')
607
- .toLowerCase()
608
- .replace(/[^a-z0-9]+/g, '-')
609
- .replace(/^-+|-+$/g, '');
610
- }