@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
@@ -4,67 +4,102 @@
4
4
  class="pui-sidebar"
5
5
  data-pui-sidebar
6
6
  :data-state="collapsed ? 'collapsed' : 'expanded'"
7
- data-drawer-state="closed"
7
+ :data-drawer-state="drawerOpen ? 'open' : 'closed'"
8
+ :data-collapsed-submenu="collapsedSubmenu"
8
9
  :aria-label="ariaLabel"
10
+ @pointerleave="onPointerLeave"
11
+ @focusout="onFocusOut"
9
12
  >
10
13
  <div
11
14
  class="pui-sidebar__backdrop"
12
15
  data-sidebar-backdrop
13
16
  aria-hidden="true"
17
+ @click="emit('update:drawerOpen', false)"
14
18
  />
15
- <div class="pui-sidebar__panel" data-sidebar-panel>
16
- <div v-if="$slots.brand" class="pui-sidebar__brand">
19
+ <div
20
+ ref="panelRef"
21
+ class="pui-sidebar__panel"
22
+ data-sidebar-panel
23
+ v-bind="drawerAria"
24
+ >
25
+ <div
26
+ v-if="$slots.brand"
27
+ ref="brandRef"
28
+ class="pui-sidebar__brand"
29
+ data-sidebar-brand
30
+ >
17
31
  <slot name="brand" />
18
32
  </div>
19
33
 
20
- <ul class="pui-sidebar__menu" data-sidebar-menu>
21
- <SidebarItemRender
22
- v-for="(item, idx) in items"
34
+ <ul class="pui-sidebar__top-nav" data-sidebar-menu>
35
+ <SidebarMenuItem
36
+ v-for="(item, idx) in topItems"
23
37
  :key="itemKey(item, idx)"
24
38
  :item="item"
25
39
  />
26
40
  </ul>
27
41
 
28
- <div v-if="$slots.footer" class="pui-sidebar__footer">
29
- <slot name="footer" />
42
+ <div
43
+ v-if="$slots.bottom || bottomItems.length"
44
+ class="pui-sidebar__bottom"
45
+ >
46
+ <ul
47
+ v-if="bottomItems.length"
48
+ class="pui-sidebar__bottom-nav"
49
+ data-sidebar-menu
50
+ >
51
+ <SidebarMenuItem
52
+ v-for="(item, idx) in bottomItems"
53
+ :key="itemKey(item, idx)"
54
+ :item="item"
55
+ />
56
+ </ul>
57
+ <slot name="bottom" />
30
58
  </div>
31
59
  </div>
32
60
  </nav>
33
61
  </template>
34
62
 
35
63
  <script setup lang="ts">
36
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
64
+ // Reactive orchestration over the primitives in
65
+ // `utils/navigation/sidebar.ts`. See ADR 0007.
66
+ import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue';
67
+ import { createFocusTrap, type FocusTrap } from '../../../utils/a11y/focus.js';
68
+ import { lockScroll } from '../../../utils/dom/scroll-lock.js';
37
69
  import {
38
- mountSidebar,
70
+ computeSubmenuPosition,
39
71
  deriveGroupKey,
40
- type SidebarControl,
41
- } from '../../../utils/controllers/sidebar.js';
42
- import SidebarItemRender from './SidebarItemRender.vue';
72
+ isSubmenuActive,
73
+ SUBMENU_CLOSE_DELAY,
74
+ type SubmenuPosition,
75
+ } from '../../../utils/navigation/sidebar.js';
76
+ import { useWindowListener } from '../../../composables/useEventListener.js';
77
+ import SidebarMenuItem from './SidebarMenuItem.vue';
78
+ import { SIDEBAR_CONTEXT } from './context.js';
43
79
  import type { SidebarItem } from './types.js';
44
80
 
45
81
  const props = withDefaults(
46
82
  defineProps<{
47
- items: SidebarItem[];
83
+ /** The main navigation — the top nav of the column. */
84
+ topItems: SidebarItem[];
85
+ /** Pinned to the foot of the column, below a divider. */
86
+ bottomItems?: SidebarItem[];
48
87
  activeHref?: string | null;
49
88
  collapsed?: boolean;
50
89
  drawerOpen?: boolean;
51
- /**
52
- * Controlled set of expanded group keys. Leave undefined to let the
53
- * controller honor `defaultOpen` markup hints — passing an explicit
54
- * array (even `[]`) signals "consumer is in control" and overrides
55
- * markup defaults.
56
- */
90
+ /** Leave undefined to honor each group's `defaultOpen`. */
57
91
  expandedGroups?: string[];
92
+ /** `'panel'` gives a collapsed group a flyout beside the rail. */
93
+ collapsedSubmenu?: 'hidden' | 'panel';
58
94
  ariaLabel?: string;
59
95
  drawerBreakpoint?: number;
60
96
  }>(),
61
97
  {
98
+ bottomItems: () => [],
62
99
  activeHref: null,
63
100
  collapsed: false,
64
101
  drawerOpen: false,
65
- // Intentionally undefined: the controller's `defaultOpen` heuristic
66
- // only fires when `config.expandedGroups` is undefined. With
67
- // `() => []`, the heuristic was permanently disabled from Vue.
102
+ collapsedSubmenu: 'hidden',
68
103
  expandedGroups: undefined,
69
104
  ariaLabel: 'Main navigation',
70
105
  drawerBreakpoint: 768,
@@ -72,13 +107,13 @@ const props = withDefaults(
72
107
  );
73
108
 
74
109
  const emit = defineEmits<{
75
- 'update:collapsed': [value: boolean];
76
110
  'update:drawerOpen': [value: boolean];
77
111
  'update:expandedGroups': [value: string[]];
78
112
  }>();
79
113
 
80
114
  const rootRef = ref<HTMLElement | null>(null);
81
- let control: SidebarControl | null = null;
115
+ const panelRef = ref<HTMLElement | null>(null);
116
+ const brandRef = ref<HTMLElement | null>(null);
82
117
 
83
118
  function itemKey(item: SidebarItem, idx: number): string {
84
119
  if (item.type === 'link') return `link:${item.href}`;
@@ -87,85 +122,292 @@ function itemKey(item: SidebarItem, idx: number): string {
87
122
  return `section:${item.label}:${idx}`;
88
123
  }
89
124
 
90
- onMounted(() => {
91
- if (!rootRef.value) return;
92
- control = mountSidebar(rootRef.value, {
93
- collapsed: props.collapsed,
94
- drawerOpen: props.drawerOpen,
95
- drawerBreakpoint: props.drawerBreakpoint,
96
- expandedGroups: props.expandedGroups,
97
- activeHref: props.activeHref,
98
- onDrawerOpenChange: (open) => emit('update:drawerOpen', open),
99
- onGroupExpandedChange: (key, expanded) => {
100
- const next = new Set(props.expandedGroups);
101
- if (expanded) next.add(key);
102
- else next.delete(key);
103
- emit('update:expandedGroups', Array.from(next));
104
- },
105
- });
106
- if (props.drawerOpen) control.openDrawer();
107
- });
125
+ function defaultOpenKeys(items: SidebarItem[]): string[] {
126
+ return items.flatMap((item) =>
127
+ item.type === 'group'
128
+ ? [
129
+ ...(item.defaultOpen
130
+ ? [item.key ?? deriveGroupKey(item.label)]
131
+ : []),
132
+ ...defaultOpenKeys(item.items),
133
+ ]
134
+ : [],
135
+ );
136
+ }
137
+
138
+ const expandedKeys = ref<Set<string>>(
139
+ new Set(
140
+ props.expandedGroups ??
141
+ defaultOpenKeys([...props.topItems, ...props.bottomItems]),
142
+ ),
143
+ );
108
144
 
109
145
  watch(
110
- () => props.collapsed,
111
- (v) => {
112
- if (!control) return;
113
- if (v) control.collapse();
114
- else control.expand();
146
+ () => props.expandedGroups,
147
+ (next) => {
148
+ if (next) expandedKeys.value = new Set(next);
115
149
  },
150
+ { deep: true },
151
+ );
152
+
153
+ const viewportWidth = ref(
154
+ typeof window === 'undefined'
155
+ ? Number.POSITIVE_INFINITY
156
+ : window.innerWidth,
157
+ );
158
+
159
+ const submenuActive = computed(() =>
160
+ isSubmenuActive({
161
+ policy: props.collapsedSubmenu,
162
+ collapsed: props.collapsed,
163
+ viewportWidth: viewportWidth.value,
164
+ drawerBreakpoint: props.drawerBreakpoint,
165
+ }),
116
166
  );
117
167
 
168
+ const openKey = ref<string | null>(null);
169
+ /** Opened by click; survives pointer-out, unlike a hover/focus open. */
170
+ const pinned = ref(false);
171
+ const submenuPos = ref<SubmenuPosition | null>(null);
172
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
173
+
174
+ function clearCloseTimer(): void {
175
+ if (closeTimer === null) return;
176
+ clearTimeout(closeTimer);
177
+ closeTimer = null;
178
+ }
179
+
180
+ function closeSubmenu(): void {
181
+ clearCloseTimer();
182
+ openKey.value = null;
183
+ pinned.value = false;
184
+ }
185
+
186
+ function scheduleClose(): void {
187
+ if (!openKey.value || pinned.value) return;
188
+ clearCloseTimer();
189
+ // Grace period so a diagonal path into the flyout doesn't drop it.
190
+ closeTimer = setTimeout(closeSubmenu, SUBMENU_CLOSE_DELAY);
191
+ }
192
+
193
+ /** One measurement serves every flyout: same edge, same height. */
194
+ function measureSubmenu(): void {
195
+ const root = rootRef.value;
196
+ if (!root) return;
197
+ submenuPos.value = computeSubmenuPosition(
198
+ root.getBoundingClientRect(),
199
+ brandRef.value?.getBoundingClientRect() ?? null,
200
+ );
201
+ }
202
+
118
203
  watch(
119
- () => props.drawerOpen,
120
- (v) => {
121
- if (!control) return;
122
- if (v) control.openDrawer();
123
- else control.closeDrawer();
204
+ submenuActive,
205
+ (active) => {
206
+ if (active) void nextTick(measureSubmenu);
207
+ else closeSubmenu();
124
208
  },
209
+ { immediate: true },
125
210
  );
126
211
 
127
- watch(
128
- () => props.activeHref,
129
- (v) => {
130
- control?.setActiveHref(v);
212
+ watch(openKey, (key) => {
213
+ if (key) void nextTick(measureSubmenu);
214
+ });
215
+
216
+ useWindowListener(
217
+ 'resize',
218
+ () => {
219
+ viewportWidth.value = window.innerWidth;
220
+ if (submenuActive.value) measureSubmenu();
131
221
  },
222
+ { passive: true },
132
223
  );
133
224
 
134
- watch(
135
- () => props.expandedGroups,
136
- (next) => {
137
- if (!control) return;
138
- // Sync each declared group: expand the ones in `next`, collapse
139
- // any currently-expanded group that's no longer in `next`.
140
- const set = new Set(next);
141
- // Walk the markup to find every group key — that's the universe.
142
- if (!rootRef.value) return;
143
- const groups = rootRef.value.querySelectorAll<HTMLElement>(
144
- '[data-sidebar-group][data-group-key]',
145
- );
146
- groups.forEach((el) => {
147
- const key = el.dataset.groupKey ?? '';
148
- if (!key) return;
149
- if (set.has(key)) control!.expandGroup(key);
150
- else control!.collapseGroup(key);
151
- });
225
+ // Capture phase catches scrolls in any ancestor.
226
+ useWindowListener(
227
+ 'scroll',
228
+ () => {
229
+ if (openKey.value) measureSubmenu();
152
230
  },
153
- { deep: true },
231
+ { capture: true, passive: true },
154
232
  );
155
233
 
234
+ function onToggle(key: string, isTopLevel: boolean): void {
235
+ // A collapsed rail pins the flyout instead of rewriting `expandedGroups`,
236
+ // which describes the expanded sidebar.
237
+ if (submenuActive.value && isTopLevel) {
238
+ if (openKey.value === key && pinned.value) {
239
+ closeSubmenu();
240
+ } else {
241
+ clearCloseTimer();
242
+ openKey.value = key;
243
+ pinned.value = true;
244
+ }
245
+ return;
246
+ }
247
+
248
+ const next = new Set(expandedKeys.value);
249
+ if (next.has(key)) next.delete(key);
250
+ else next.add(key);
251
+ expandedKeys.value = next;
252
+ emit('update:expandedGroups', [...next]);
253
+ }
254
+
255
+ /** Pointer and focus share this path, so the two cannot drift apart. */
256
+ function onEntryEnter(topLevelKey: string | null): void {
257
+ if (!submenuActive.value) return;
258
+ if (topLevelKey) {
259
+ clearCloseTimer();
260
+ if (openKey.value !== topLevelKey) {
261
+ openKey.value = topLevelKey;
262
+ pinned.value = false;
263
+ }
264
+ return;
265
+ }
266
+ if (!pinned.value) scheduleClose();
267
+ }
268
+
269
+ provide(SIDEBAR_CONTEXT, {
270
+ activeHref: computed(() => props.activeHref),
271
+ expandedKeys,
272
+ openKey,
273
+ submenuActive,
274
+ submenuPos,
275
+ onToggle,
276
+ onEntryEnter,
277
+ onEntryKeydown,
278
+ });
279
+
280
+ function onPointerLeave(): void {
281
+ if (!submenuActive.value || pinned.value) return;
282
+ scheduleClose();
283
+ }
284
+
285
+ function onFocusOut(event: FocusEvent): void {
286
+ if (!openKey.value) return;
287
+ const next = event.relatedTarget;
288
+ // A pin survives the pointer, not the focus.
289
+ if (next instanceof Node && rootRef.value?.contains(next)) return;
290
+ closeSubmenu();
291
+ }
292
+
293
+ /** Entries in DOM order that focus can actually reach. */
294
+ function focusableEntries(): HTMLElement[] {
295
+ const root = rootRef.value;
296
+ if (!root) return [];
297
+ return [
298
+ ...root.querySelectorAll<HTMLElement>(
299
+ '[data-sidebar-menu-item]:not([data-disabled="true"])',
300
+ ),
301
+ // `visibilityProperty` is not the default, and a closed flyout hides
302
+ // with `visibility` — without it, Arrow-Down walks into dead entries.
303
+ ].filter((el) => el.checkVisibility({ visibilityProperty: true }));
304
+ }
305
+
306
+ function onEntryKeydown(event: KeyboardEvent): void {
307
+ // The flyout is the innermost dismissible layer, so it goes first.
308
+ if (event.key === 'Escape' && openKey.value) {
309
+ event.preventDefault();
310
+ const selector = `[data-group-key="${CSS.escape(openKey.value)}"] [data-sidebar-group-toggle]`;
311
+ const toggle = rootRef.value?.querySelector<HTMLElement>(selector);
312
+ closeSubmenu();
313
+ toggle?.focus();
314
+ return;
315
+ }
316
+
317
+ if (event.key === 'Escape' && props.drawerOpen) {
318
+ event.preventDefault();
319
+ emit('update:drawerOpen', false);
320
+ return;
321
+ }
322
+
323
+ const item = event.currentTarget;
324
+ if (!(item instanceof HTMLElement)) return;
325
+
326
+ const entries = focusableEntries();
327
+ const idx = entries.indexOf(item);
328
+
329
+ switch (event.key) {
330
+ case 'ArrowDown':
331
+ event.preventDefault();
332
+ entries[Math.min(idx + 1, entries.length - 1)]?.focus();
333
+ return;
334
+ case 'ArrowUp':
335
+ event.preventDefault();
336
+ entries[Math.max(idx - 1, 0)]?.focus();
337
+ return;
338
+ case 'Home':
339
+ event.preventDefault();
340
+ entries[0]?.focus();
341
+ return;
342
+ case 'End':
343
+ event.preventDefault();
344
+ entries[entries.length - 1]?.focus();
345
+ return;
346
+ case ' ':
347
+ case 'Spacebar':
348
+ // `<a>` doesn't activate on Space natively.
349
+ if (item.matches('[data-sidebar-link]')) {
350
+ event.preventDefault();
351
+ item.click();
352
+ }
353
+ return;
354
+ default:
355
+ return;
356
+ }
357
+ }
358
+
359
+ const drawerAria = computed(() =>
360
+ props.drawerOpen
361
+ ? {
362
+ role: 'dialog',
363
+ // Booleans, so Vue renders "true"/"false" instead of dropping it.
364
+ 'aria-modal': true,
365
+ 'aria-label': props.ariaLabel,
366
+ }
367
+ : {},
368
+ );
369
+
370
+ let trap: FocusTrap | null = null;
371
+ let unlockScroll: (() => void) | null = null;
372
+ let triggerEl: HTMLElement | null = null;
373
+
156
374
  watch(
157
- () => props.items,
158
- () => {
159
- // Item shape changed → re-derive active-href on the new DOM.
160
- // Vue re-renders the `<ul>` first; defer one tick.
161
- queueMicrotask(() => control?.setActiveHref(props.activeHref));
375
+ () => props.drawerOpen,
376
+ async (open) => {
377
+ if (open) {
378
+ const active = document.activeElement;
379
+ triggerEl =
380
+ active instanceof HTMLElement &&
381
+ active !== document.body &&
382
+ !rootRef.value?.contains(active)
383
+ ? active
384
+ : null;
385
+
386
+ unlockScroll = lockScroll();
387
+ await nextTick();
388
+ if (!panelRef.value) return;
389
+ trap = createFocusTrap(panelRef.value);
390
+ const first = focusableEntries()[0];
391
+ if (first) first.focus();
392
+ else panelRef.value.focus();
393
+ return;
394
+ }
395
+
396
+ trap?.release();
397
+ trap = null;
398
+ unlockScroll?.();
399
+ unlockScroll = null;
400
+
401
+ const trigger = triggerEl;
402
+ triggerEl = null;
403
+ if (trigger && document.contains(trigger)) trigger.focus();
162
404
  },
163
- { deep: true },
164
405
  );
165
406
 
166
407
  onBeforeUnmount(() => {
167
- control?.teardown();
168
- control = null;
408
+ clearCloseTimer();
409
+ trap?.release();
410
+ unlockScroll?.();
169
411
  });
170
412
  </script>
171
413
 
@@ -0,0 +1,199 @@
1
+ <template>
2
+ <li v-if="item.type === 'link'" class="pui-sidebar__item">
3
+ <a
4
+ class="pui-sidebar__link"
5
+ data-sidebar-menu-item
6
+ data-sidebar-link
7
+ :href="item.href"
8
+ :data-disabled="item.disabled ? 'true' : undefined"
9
+ :aria-disabled="item.disabled ? 'true' : undefined"
10
+ :tabindex="item.disabled ? '-1' : undefined"
11
+ :aria-current="isCurrent ? 'page' : undefined"
12
+ @pointerover="ctx.onEntryEnter(topLevelKey)"
13
+ @focus="ctx.onEntryEnter(topLevelKey)"
14
+ @keydown="ctx.onEntryKeydown"
15
+ >
16
+ <Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
17
+ <span class="pui-sidebar__link-label">{{ item.label }}</span>
18
+ <span v-if="item.badge !== undefined" class="pui-sidebar__badge">{{
19
+ item.badge
20
+ }}</span>
21
+ </a>
22
+ </li>
23
+
24
+ <li
25
+ v-else-if="item.type === 'group'"
26
+ class="pui-sidebar__group"
27
+ data-sidebar-group
28
+ :data-group-key="groupKey"
29
+ :data-default-open="item.defaultOpen ? 'true' : undefined"
30
+ :data-state="isExpanded ? 'expanded' : 'collapsed'"
31
+ :data-active="containsCurrent ? 'true' : undefined"
32
+ :data-submenu-state="submenuState"
33
+ >
34
+ <!-- With an `href` the row is a link and the caret is its own
35
+ toggle; one control cannot both navigate and expand in place.
36
+ Without one, the whole row is the toggle. -->
37
+ <component
38
+ :is="item.href ? 'a' : 'button'"
39
+ class="pui-sidebar__group-toggle"
40
+ data-sidebar-menu-item
41
+ data-sidebar-group-toggle
42
+ :data-sidebar-link="item.href ? '' : undefined"
43
+ :type="item.href ? undefined : 'button'"
44
+ :href="item.href"
45
+ :aria-current="isCurrent ? 'page' : undefined"
46
+ :aria-expanded="
47
+ item.href && !isFlyout ? undefined : isFlyoutOrExpanded
48
+ "
49
+ :aria-controls="item.href && !isFlyout ? undefined : itemsId"
50
+ @click="item.href ? undefined : ctx.onToggle(groupKey, isTopLevel)"
51
+ @pointerover="ctx.onEntryEnter(ownKey)"
52
+ @focus="ctx.onEntryEnter(ownKey)"
53
+ @keydown="ctx.onEntryKeydown"
54
+ >
55
+ <Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
56
+ <span class="pui-sidebar__link-label">{{ item.label }}</span>
57
+ <Icon
58
+ v-if="!item.href"
59
+ name="chevron-down"
60
+ size="sm"
61
+ class="pui-sidebar__group-caret"
62
+ />
63
+ </component>
64
+ <button
65
+ v-if="item.href"
66
+ type="button"
67
+ class="pui-sidebar__group-caret-toggle"
68
+ :aria-expanded="isFlyoutOrExpanded"
69
+ :aria-controls="itemsId"
70
+ :aria-label="`${isFlyoutOrExpanded ? 'Collapse' : 'Expand'} ${item.label}`"
71
+ @click="ctx.onToggle(groupKey, isTopLevel)"
72
+ >
73
+ <Icon
74
+ name="chevron-down"
75
+ size="sm"
76
+ class="pui-sidebar__group-caret"
77
+ />
78
+ </button>
79
+ <ul
80
+ :id="itemsId"
81
+ class="pui-sidebar__group-items"
82
+ data-sidebar-group-items
83
+ role="group"
84
+ :hidden="isFlyout ? false : !isExpanded"
85
+ :style="flyoutStyle"
86
+ >
87
+ <SidebarMenuItem
88
+ v-for="(child, idx) in item.items"
89
+ :key="childKey(child, idx)"
90
+ :item="child"
91
+ :top-level-key="ownKey"
92
+ />
93
+ </ul>
94
+ </li>
95
+
96
+ <li
97
+ v-else-if="item.type === 'section'"
98
+ class="pui-sidebar__section"
99
+ role="presentation"
100
+ >
101
+ <span class="pui-sidebar__section-label">{{ item.label }}</span>
102
+ </li>
103
+ </template>
104
+
105
+ <script setup lang="ts">
106
+ import { computed, inject, type CSSProperties } from 'vue';
107
+ import Icon from '../../display/icon/Icon.vue';
108
+ import type { IconName } from '../../../icons/index.js';
109
+ import { generateId } from '../../../utils/a11y/id.js';
110
+ import {
111
+ deriveGroupKey,
112
+ groupContainsActive,
113
+ isHrefActive,
114
+ } from '../../../utils/navigation/sidebar.js';
115
+ import { SIDEBAR_CONTEXT } from './context.js';
116
+ import type { SidebarItem } from './types.js';
117
+
118
+ const props = withDefaults(
119
+ defineProps<{
120
+ item: SidebarItem;
121
+ /** The rail group this entry belongs to; null if it has none. */
122
+ topLevelKey?: string | null;
123
+ }>(),
124
+ { topLevelKey: null },
125
+ );
126
+
127
+ const ctx = inject(SIDEBAR_CONTEXT)!;
128
+
129
+ const itemsId = generateId('pui-sidebar-group');
130
+
131
+ const groupKey = computed(() =>
132
+ props.item.type === 'group'
133
+ ? (props.item.key ?? deriveGroupKey(props.item.label))
134
+ : '',
135
+ );
136
+
137
+ const isTopLevel = computed(() => props.topLevelKey === null);
138
+
139
+ /** What descendants inherit as their `topLevelKey`. */
140
+ const ownKey = computed(() =>
141
+ props.item.type === 'group' && isTopLevel.value
142
+ ? groupKey.value
143
+ : props.topLevelKey,
144
+ );
145
+
146
+ const isCurrent = computed(() => {
147
+ const href =
148
+ props.item.type === 'link'
149
+ ? props.item.href
150
+ : props.item.type === 'group'
151
+ ? props.item.href
152
+ : undefined;
153
+ return href !== undefined && isHrefActive(ctx.activeHref.value, href);
154
+ });
155
+
156
+ const containsCurrent = computed(
157
+ () =>
158
+ props.item.type === 'group' &&
159
+ groupContainsActive(props.item.items, ctx.activeHref.value),
160
+ );
161
+
162
+ const isExpanded = computed(() => ctx.expandedKeys.value.has(groupKey.value));
163
+
164
+ const isFlyout = computed(
165
+ () =>
166
+ ctx.submenuActive.value &&
167
+ props.item.type === 'group' &&
168
+ isTopLevel.value,
169
+ );
170
+
171
+ const isFlyoutOpen = computed(
172
+ () => isFlyout.value && ctx.openKey.value === groupKey.value,
173
+ );
174
+
175
+ /** What `aria-expanded` reports: flyout state on the rail, else disclosure. */
176
+ const isFlyoutOrExpanded = computed(() =>
177
+ isFlyout.value ? isFlyoutOpen.value : isExpanded.value,
178
+ );
179
+
180
+ // The stylesheet keys on the attribute's presence, not just its value.
181
+ const submenuState = computed(() =>
182
+ isFlyout.value ? (isFlyoutOpen.value ? 'open' : 'closed') : undefined,
183
+ );
184
+
185
+ // Positioned even when closed: a fixed box with no coordinates falls back
186
+ // to its static position, inside the rail.
187
+ const flyoutStyle = computed<CSSProperties | undefined>(() => {
188
+ if (!isFlyout.value || !ctx.submenuPos.value) return undefined;
189
+ const { left, top, height } = ctx.submenuPos.value;
190
+ return { left: `${left}px`, top: `${top}px`, height: `${height}px` };
191
+ });
192
+
193
+ function childKey(child: SidebarItem, idx: number): string {
194
+ if (child.type === 'link') return `link:${child.href}`;
195
+ if (child.type === 'group')
196
+ return `group:${child.key ?? deriveGroupKey(child.label)}`;
197
+ return `section:${child.label}:${idx}`;
198
+ }
199
+ </script>