@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
@@ -0,0 +1,21 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue';
2
+ import type { SubmenuPosition } from '../../../utils/navigation/sidebar.js';
3
+
4
+ /** What `Sidebar.vue` hands down to the recursive `SidebarMenuItem`. */
5
+ export interface SidebarContext {
6
+ activeHref: ComputedRef<string | null>;
7
+ expandedKeys: Ref<Set<string>>;
8
+ /** Group key whose collapsed-rail flyout is open, or null. */
9
+ openKey: Ref<string | null>;
10
+ submenuActive: ComputedRef<boolean>;
11
+ /** Shared by every flyout; null before the first measure. */
12
+ submenuPos: Ref<SubmenuPosition | null>;
13
+
14
+ onToggle: (key: string, isTopLevel: boolean) => void;
15
+ /** `topLevelKey` is null for a plain entry, which dismisses the flyout. */
16
+ onEntryEnter: (topLevelKey: string | null) => void;
17
+ onEntryKeydown: (event: KeyboardEvent) => void;
18
+ }
19
+
20
+ export const SIDEBAR_CONTEXT: InjectionKey<SidebarContext> =
21
+ Symbol('pui-sidebar');
@@ -1,10 +1,5 @@
1
1
  @layer components {
2
- /* ============================================================
3
- * Block — the navigation landmark, also the drawer mount point on
4
- * mobile. Desktop: a fixed-width column owned by the consumer's
5
- * layout shell. Mobile: occupies the full viewport with a backdrop
6
- * sibling and a slide-in panel.
7
- * ============================================================ */
2
+ /* Block — the navigation landmark, also the drawer mount point on mobile */
8
3
  .pui-sidebar {
9
4
  --pui-sidebar-width: var(--space-sidebar-width);
10
5
  --pui-sidebar-width-collapsed: var(--space-sidebar-width-collapsed);
@@ -21,10 +16,19 @@
21
16
  width: var(--pui-sidebar-width-collapsed);
22
17
  }
23
18
 
24
- /* ============================================================
25
- * Panel the visible column / drawer body containing brand, menu,
26
- * footer.
27
- * ============================================================ */
19
+ /* Lift the root, not the flyout: a fixed child escapes overflow but not
20
+ * a stacking context. Scoped to "flyout open" because this rule is itself
21
+ * a context trigger, and unscoped it trapped the mobile drawer. */
22
+ @media (min-width: 768px) {
23
+ .pui-sidebar:has(.pui-sidebar__group[data-submenu-state='open']) {
24
+ /* `--layer-below` only means "behind the rail" while the sidebar
25
+ is the context containing it. */
26
+ isolation: isolate;
27
+ z-index: var(--layer-3);
28
+ }
29
+ }
30
+
31
+ /* Panel — the visible column / drawer body containing brand, menu, footer */
28
32
  .pui-sidebar__panel {
29
33
  display: flex;
30
34
  flex-direction: column;
@@ -35,17 +39,12 @@
35
39
  background: var(--bg-clr-surface);
36
40
  }
37
41
 
38
- /* ============================================================
39
- * Backdrop — only meaningful in mobile drawer mode. Hidden on desktop.
40
- * ============================================================ */
42
+ /* Backdrop — only meaningful in mobile drawer mode */
41
43
  .pui-sidebar__backdrop {
42
44
  display: none;
43
45
  }
44
46
 
45
- /* ============================================================
46
- * Brand region — top of the panel. Layout owned by consumer markup
47
- * inside the slot; we just provide consistent spacing.
48
- * ============================================================ */
47
+ /* Brand region — top of the panel */
49
48
  .pui-sidebar__brand {
50
49
  display: flex;
51
50
  align-items: center;
@@ -53,29 +52,48 @@
53
52
  padding-block: var(--space-2xs);
54
53
  }
55
54
 
56
- /* Hide brand inner text when collapsed; consumer markup with
57
- * `data-keep-when-collapsed` survives. */
55
+ /* `data-keep-when-collapsed` survives; the rest would spill out of the
56
+ * rail, which is narrower than most product names. */
58
57
  .pui-sidebar[data-state='collapsed']
59
58
  .pui-sidebar__brand
60
59
  > :not([data-keep-when-collapsed]) {
61
- /* leave layout untouched — many consumers want a wordmark to fade
62
- * out while the mark stays — this is the default rule. Consumers
63
- * mark the persistent element. */
60
+ display: none;
61
+ }
62
+
63
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__brand {
64
+ justify-content: center;
64
65
  }
65
66
 
66
- /* ============================================================
67
- * Menu list — the data-driven menu region.
68
- * ============================================================ */
69
- .pui-sidebar__menu {
67
+ /* Nav lists — the two data-driven regions */
68
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav) {
70
69
  list-style: none;
71
70
  margin: 0;
72
71
  padding: 0;
73
72
  display: flex;
74
73
  flex-direction: column;
75
74
  gap: var(--space-3xs);
75
+ }
76
+
77
+ .pui-sidebar__top-nav {
78
+ --pui-sidebar-ring-space: calc(
79
+ var(--outline-offset) + var(--outline-width)
80
+ );
81
+
76
82
  flex: 1;
77
83
  min-height: 0;
78
84
  overflow-y: auto;
85
+
86
+ /* A scroll container clips at its padding box, which sliced the focus
87
+ ring off. The padding makes room inside the clip, the negative margin
88
+ puts the list back. */
89
+ padding-inline: var(--pui-sidebar-ring-space);
90
+ margin-inline: calc(var(--pui-sidebar-ring-space) * -1);
91
+ }
92
+
93
+ /* Only as tall as its entries, and it must not clip its own flyouts. */
94
+ .pui-sidebar__bottom-nav {
95
+ flex: none;
96
+ overflow: visible;
79
97
  }
80
98
 
81
99
  .pui-sidebar__item {
@@ -84,10 +102,7 @@
84
102
  padding: 0;
85
103
  }
86
104
 
87
- /* ============================================================
88
- * Link / group-toggle — share visual treatment so the menu reads
89
- * as one column of equally-weighted entries.
90
- * ============================================================ */
105
+ /* Link / group-toggle — share visual treatment so the menu reads as one column of equally-weighted entries */
91
106
  .pui-sidebar__link,
92
107
  .pui-sidebar__group-toggle {
93
108
  display: flex;
@@ -113,8 +128,9 @@
113
128
  color: var(--text-clr-brand);
114
129
  }
115
130
 
131
+ /* Not the neutral fill hover uses, or the two are indistinguishable. */
116
132
  .pui-sidebar__link[aria-current='page'] {
117
- background: var(--bg-clr-accent);
133
+ background: var(--bg-clr-brand-soft);
118
134
  color: var(--text-clr-brand-selected);
119
135
  }
120
136
 
@@ -123,10 +139,19 @@
123
139
  pointer-events: none;
124
140
  }
125
141
 
142
+ /* Outset. Colour follows the entry's tone as `--btn-ring` does for
143
+ * Button, so the ring never fights the fill it sits on. */
126
144
  .pui-sidebar__link:focus-visible,
127
- .pui-sidebar__group-toggle:focus-visible {
128
- outline: 3px solid var(--outline-clr-base);
129
- outline-offset: 2px;
145
+ .pui-sidebar__group-toggle:focus-visible,
146
+ .pui-sidebar__group-caret-toggle:focus-visible {
147
+ outline: var(--outline-width) solid
148
+ var(--pui-sidebar-ring, var(--border-clr-strong));
149
+ outline-offset: var(--outline-offset);
150
+ }
151
+
152
+ .pui-sidebar__link[aria-current='page'],
153
+ .pui-sidebar__group[data-active='true'] > .pui-sidebar__group-toggle {
154
+ --pui-sidebar-ring: var(--outline-clr-base);
130
155
  }
131
156
 
132
157
  .pui-sidebar__link-label {
@@ -150,9 +175,7 @@
150
175
  text-align: center;
151
176
  }
152
177
 
153
- /* ============================================================
154
- * Group — collapsible container.
155
- * ============================================================ */
178
+ /* Group — collapsible container */
156
179
  .pui-sidebar__group {
157
180
  list-style: none;
158
181
  margin: 0;
@@ -164,6 +187,44 @@
164
187
  transition: transform 150ms ease;
165
188
  }
166
189
 
190
+ /* Only when the group carries an `href` — the row navigates, so the
191
+ * disclosure needs its own control. */
192
+ .pui-sidebar__group-caret-toggle {
193
+ display: flex;
194
+ align-items: center;
195
+ margin-inline-start: auto;
196
+ padding: var(--space-3xs);
197
+ border: 0;
198
+ border-radius: var(--radius-sm);
199
+ background: transparent;
200
+ color: inherit;
201
+ cursor: pointer;
202
+
203
+ & .pui-sidebar__group-caret {
204
+ margin-inline-start: 0;
205
+ }
206
+
207
+ &:hover {
208
+ background: var(--bg-clr-accent);
209
+ color: var(--text-clr-brand);
210
+ }
211
+ }
212
+
213
+ .pui-sidebar__group:has(> .pui-sidebar__group-caret-toggle) {
214
+ display: flex;
215
+ align-items: center;
216
+ flex-wrap: wrap;
217
+
218
+ & > .pui-sidebar__group-toggle {
219
+ flex: 1;
220
+ min-width: 0;
221
+ }
222
+
223
+ & > .pui-sidebar__group-items {
224
+ flex-basis: 100%;
225
+ }
226
+ }
227
+
167
228
  .pui-sidebar__group-toggle[aria-expanded='true'] .pui-sidebar__group-caret {
168
229
  transform: rotate(180deg);
169
230
  }
@@ -186,9 +247,7 @@
186
247
  display: none;
187
248
  }
188
249
 
189
- /* ============================================================
190
- * Section header — non-interactive label.
191
- * ============================================================ */
250
+ /* Section header — non-interactive label */
192
251
  .pui-sidebar__section {
193
252
  list-style: none;
194
253
  margin: 0;
@@ -203,48 +262,190 @@
203
262
  color: var(--text-clr-muted);
204
263
  }
205
264
 
206
- /* ============================================================
207
- * Footer region — bottom of the panel.
208
- * ============================================================ */
209
- .pui-sidebar__footer {
265
+ /* Bottom region — foot of the panel */
266
+ .pui-sidebar__bottom {
210
267
  margin-block-start: auto;
211
268
  padding-block-start: var(--space-2xs);
212
269
  border-block-start: var(--stroke-sm) solid var(--border-clr-base);
213
270
  }
214
271
 
215
- /* ============================================================
216
- * Collapsed-state visual rules hide labels and badges, keep icons.
217
- * Section headers also hidden because they're label-only.
218
- * ============================================================ */
219
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__link-label,
220
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__badge,
221
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-caret,
222
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__section {
223
- display: none;
272
+ /* Collapsed — the icon rail. Every selector is scoped with `>` to
273
+ * entries directly in a nav list; a descendant selector would reach into
274
+ * an open flyout and collapse its contents too. */
275
+
276
+ /* The expanded padding would leave ~2rem for a caption. Wide enough
277
+ * here to also hold the focus ring. */
278
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__panel {
279
+ padding-inline: var(--space-2xs);
224
280
  }
225
281
 
226
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__link,
227
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-toggle {
282
+ .pui-sidebar[data-state='collapsed']
283
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
284
+ > .pui-sidebar__item
285
+ > .pui-sidebar__link,
286
+ .pui-sidebar[data-state='collapsed']
287
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
288
+ > .pui-sidebar__group
289
+ > .pui-sidebar__group-toggle {
290
+ flex-direction: column;
228
291
  justify-content: center;
292
+ gap: var(--space-3xs);
229
293
  padding-inline: var(--space-3xs);
294
+ text-align: center;
295
+ }
296
+
297
+ .pui-sidebar[data-state='collapsed']
298
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
299
+ > .pui-sidebar__item
300
+ > .pui-sidebar__link
301
+ > .pui-sidebar__link-label,
302
+ .pui-sidebar[data-state='collapsed']
303
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
304
+ > .pui-sidebar__group
305
+ > .pui-sidebar__group-toggle
306
+ > .pui-sidebar__link-label {
307
+ flex: none;
308
+ max-width: 100%;
309
+ font-size: var(--step--3);
310
+ font-weight: var(--fw-regular);
311
+ line-height: var(--lh-tight);
312
+ /* Wrap rather than ellipsise — an ellipsis hides what the caption is
313
+ for. `break-word`, not `anywhere`: `anywhere` shrinks min-content
314
+ width, so words break even when they would have fit. */
315
+ white-space: normal;
316
+ overflow-wrap: break-word;
317
+ display: -webkit-box;
318
+ -webkit-box-orient: vertical;
319
+ -webkit-line-clamp: 2;
320
+ line-clamp: 2;
230
321
  }
231
322
 
232
- /* In collapsed mode we also hide nested group items (they'd
233
- * overflow the narrow column). Hover-to-expand with tooltip is a
234
- * Phase 4 deferral. */
235
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-items {
323
+ /* Scoped to the rail entry's own badge; a descendant combinator would
324
+ * strip the flyout's too. */
325
+ .pui-sidebar[data-state='collapsed']
326
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
327
+ > .pui-sidebar__item
328
+ > .pui-sidebar__link
329
+ > .pui-sidebar__badge,
330
+ .pui-sidebar[data-state='collapsed']
331
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
332
+ > .pui-sidebar__group
333
+ > .pui-sidebar__group-toggle
334
+ > .pui-sidebar__group-caret,
335
+ .pui-sidebar[data-state='collapsed']
336
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
337
+ > .pui-sidebar__group
338
+ > .pui-sidebar__group-caret-toggle,
339
+ .pui-sidebar[data-state='collapsed']
340
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
341
+ > .pui-sidebar__section {
236
342
  display: none;
237
343
  }
238
344
 
239
- /* ============================================================
240
- * Mobile drawer mode below the breakpoint, the column transforms
241
- * into a fixed-overlay drawer. The breakpoint default lives in JS
242
- * (768px), CSS mirrors with the same number. If a consumer changes
243
- * `:drawer-breakpoint`, they're responsible for matching CSS via
244
- * a custom stylesheet — out of scope for v1.
245
- * ============================================================ */
345
+ /* Marks which icon the open flyout came from — hover can't, once the
346
+ * flyout was opened by keyboard. Before the selected rule so that wins. */
347
+ .pui-sidebar[data-state='collapsed']
348
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
349
+ > .pui-sidebar__group
350
+ > .pui-sidebar__group-toggle[aria-expanded='true'] {
351
+ background: var(--bg-clr-accent);
352
+ color: var(--text-clr-brand);
353
+ }
354
+
355
+ /* The rail hides its children, so the group is the only indicator of
356
+ * location and a tint alone loses to the hover fill. Reverted in drawer
357
+ * mode, where it would merge with its own current child. */
358
+ .pui-sidebar[data-state='collapsed']
359
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
360
+ > .pui-sidebar__group[data-active='true']
361
+ > .pui-sidebar__group-toggle {
362
+ background: var(--bg-clr-brand-soft);
363
+ color: var(--text-clr-brand-selected);
364
+ }
365
+
366
+ /* Default policy; `collapsedSubmenu="panel"` overrides it below. */
367
+ .pui-sidebar[data-state='collapsed']
368
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
369
+ > .pui-sidebar__group
370
+ > .pui-sidebar__group-items {
371
+ display: none;
372
+ }
373
+
374
+ /* Collapsed submenu — the flyout. `data-submenu-state`'s presence is the
375
+ * switch, its value the state. Fixed, with coordinates set by the tier, so
376
+ * it escapes the nav's `overflow-y: auto` without portalling. */
377
+ .pui-sidebar[data-state='collapsed']
378
+ .pui-sidebar__group[data-submenu-state]
379
+ > .pui-sidebar__group-items {
380
+ display: flex;
381
+ position: fixed;
382
+ /* Under the rail: the panel is in-flow inside the isolated root, and
383
+ in-flow content paints above negative rungs. Giving the panel a rung
384
+ would make it a context and put its own flyout back on top. */
385
+ z-index: var(--layer-below);
386
+ width: var(--pui-sidebar-width);
387
+ padding: var(--space-xs) var(--space-2xs);
388
+ overflow-y: auto;
389
+ background: var(--bg-clr-surface);
390
+ border-inline-end: var(--stroke-sm) solid var(--border-clr-base);
391
+ box-shadow: var(--shadow-lg);
392
+ /* No transition here: a transition belongs to the state moved *to*,
393
+ so declaring it only on `open` makes hiding instant. */
394
+ }
395
+
396
+ .pui-sidebar[data-state='collapsed']
397
+ .pui-sidebar__group[data-submenu-state='closed']
398
+ > .pui-sidebar__group-items {
399
+ /* `visibility`, not `hidden`: keeps a box to transition while staying
400
+ out of the tab order and the a11y tree. */
401
+ visibility: hidden;
402
+ opacity: 0;
403
+ /* Where the slide starts. */
404
+ transform: translateX(-100%);
405
+ /* Explicit so it survives a transition being added above. */
406
+ transition: none;
407
+ }
408
+
409
+ .pui-sidebar[data-state='collapsed']
410
+ .pui-sidebar__group[data-submenu-state='open']
411
+ > .pui-sidebar__group-items {
412
+ visibility: visible;
413
+ opacity: 1;
414
+ transform: none;
415
+ /* `visibility` is deliberately absent: untransitioned it flips at
416
+ once, so Tab reaches the entries instead of walking past them. */
417
+ transition:
418
+ opacity var(--duration-fast) var(--ease-out),
419
+ transform var(--duration-base) var(--ease-out);
420
+ transition-delay: var(--duration-fast);
421
+ }
422
+
423
+ /* Full-width rows again, so the expanded tree's indent is dropped. */
424
+ .pui-sidebar__group[data-submenu-state] > .pui-sidebar__group-items {
425
+ padding-inline-start: var(--space-2xs);
426
+ }
427
+
428
+ /* It travels, so it needs its own reduced-motion escape. */
429
+ @media (prefers-reduced-motion: reduce) {
430
+ .pui-sidebar[data-state='collapsed']
431
+ .pui-sidebar__group[data-submenu-state]
432
+ > .pui-sidebar__group-items {
433
+ transition: none;
434
+ }
435
+
436
+ .pui-sidebar[data-state='collapsed']
437
+ .pui-sidebar__group[data-submenu-state='closed']
438
+ > .pui-sidebar__group-items {
439
+ transform: none;
440
+ }
441
+ }
442
+
443
+ /* Mobile drawer mode — below the breakpoint, the column transforms into a fixed-overlay drawer */
246
444
  @media (max-width: 767px) {
247
- .pui-sidebar {
445
+ .pui-sidebar,
446
+ /* Repeats `[data-state]`: a media query grants no extra specificity,
447
+ so the plain selector alone loses to the collapsed rule above. */
448
+ .pui-sidebar[data-state='collapsed'] {
248
449
  width: 0;
249
450
  border-inline-end: none;
250
451
  transition: none;
@@ -258,13 +459,20 @@
258
459
  box-shadow: var(--shadow-spark-lg);
259
460
  transform: translateX(-100%);
260
461
  transition: transform 220ms ease;
261
- z-index: 50;
462
+ z-index: var(--layer-5);
262
463
  }
263
464
 
264
465
  .pui-sidebar[data-drawer-state='open'] .pui-sidebar__panel {
265
466
  transform: translateX(0);
266
467
  }
267
468
 
469
+ /* Lift the whole root: the panel's own rung only ranks it against
470
+ siblings, and a consumer's sticky sidebar makes the root a context
471
+ that an app header can paint over. Inert if the root is static. */
472
+ .pui-sidebar[data-drawer-state='open'] {
473
+ z-index: var(--layer-5);
474
+ }
475
+
268
476
  .pui-sidebar__backdrop {
269
477
  display: block;
270
478
  position: fixed;
@@ -273,7 +481,7 @@
273
481
  opacity: 0;
274
482
  pointer-events: none;
275
483
  transition: opacity 220ms ease;
276
- z-index: 40;
484
+ z-index: var(--layer-4);
277
485
  }
278
486
 
279
487
  .pui-sidebar[data-drawer-state='open'] .pui-sidebar__backdrop {
@@ -281,23 +489,83 @@
281
489
  pointer-events: auto;
282
490
  }
283
491
 
284
- /* In drawer mode, the desktop "collapsed" state is irrelevant
285
- * the panel is full-width drawer when open and hidden otherwise. */
286
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__link-label,
287
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__badge,
288
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-caret,
289
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__section {
492
+ /* Collapsed is irrelevant here. Each revert mirrors the scoping of
493
+ * the rule it undoes a media query grants no extra specificity. The
494
+ * flyout needs none: `data-submenu-state` is absent below the
495
+ * breakpoint. */
496
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__panel {
497
+ padding-inline: var(--space-s);
498
+ }
499
+
500
+ .pui-sidebar[data-state='collapsed']
501
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
502
+ > .pui-sidebar__item
503
+ > .pui-sidebar__link
504
+ > .pui-sidebar__badge,
505
+ .pui-sidebar[data-state='collapsed']
506
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
507
+ > .pui-sidebar__group
508
+ > .pui-sidebar__group-toggle
509
+ > .pui-sidebar__group-caret,
510
+ .pui-sidebar[data-state='collapsed']
511
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
512
+ > .pui-sidebar__section {
290
513
  display: revert;
291
514
  }
292
515
 
293
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__link,
294
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-toggle {
516
+ /* The caret toggle is the disclosure here, so it comes back. */
517
+ .pui-sidebar[data-state='collapsed']
518
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
519
+ > .pui-sidebar__group
520
+ > .pui-sidebar__group-caret-toggle {
521
+ display: flex;
522
+ }
523
+
524
+ .pui-sidebar[data-state='collapsed']
525
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
526
+ > .pui-sidebar__item
527
+ > .pui-sidebar__link,
528
+ .pui-sidebar[data-state='collapsed']
529
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
530
+ > .pui-sidebar__group
531
+ > .pui-sidebar__group-toggle {
532
+ flex-direction: row;
295
533
  justify-content: flex-start;
534
+ gap: var(--space-2xs);
296
535
  padding-inline: var(--space-xs);
536
+ text-align: start;
537
+ }
538
+
539
+ .pui-sidebar[data-state='collapsed']
540
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
541
+ > .pui-sidebar__item
542
+ > .pui-sidebar__link
543
+ > .pui-sidebar__link-label,
544
+ .pui-sidebar[data-state='collapsed']
545
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
546
+ > .pui-sidebar__group
547
+ > .pui-sidebar__group-toggle
548
+ > .pui-sidebar__link-label {
549
+ flex: 1;
550
+ font-size: var(--step--1);
551
+ font-weight: var(--fw-semibold);
552
+ line-height: var(--lh-base);
297
553
  }
298
554
 
299
- .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-items {
555
+ .pui-sidebar[data-state='collapsed']
556
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
557
+ > .pui-sidebar__group
558
+ > .pui-sidebar__group-items {
300
559
  display: flex;
301
560
  }
561
+
562
+ /* The children are visible here, so the same fill on both ran them
563
+ together into one block. */
564
+ .pui-sidebar[data-state='collapsed']
565
+ :is(.pui-sidebar__top-nav, .pui-sidebar__bottom-nav)
566
+ > .pui-sidebar__group[data-active='true']
567
+ > .pui-sidebar__group-toggle {
568
+ background: transparent;
569
+ }
302
570
  }
303
571
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Sidebar driver-data discriminated union.
3
3
  *
4
- * The Sidebar component takes an `:items` prop of `SidebarItem[]`. Each
4
+ * The Sidebar takes `:top-items` (and optionally `:bottom-items`)
5
+ * of `SidebarItem[]`. Each
5
6
  * entry is one of three shapes, distinguished by the `type` field:
6
7
  *
7
8
  * - `link` — a navigable menu entry (renders as `<a>`)
@@ -42,6 +43,17 @@ export interface SidebarGroup {
42
43
  label: string;
43
44
  /** Optional leading icon on the group toggle. */
44
45
  icon?: string;
46
+ /**
47
+ * Makes the group a destination as well as a disclosure — the row
48
+ * becomes a link, matched against `activeHref` like any other.
49
+ *
50
+ * With an `href` the row navigates and the caret becomes a separate
51
+ * toggle button, because one control cannot both go somewhere and
52
+ * expand in place. On the collapsed rail there is no caret: the row
53
+ * still navigates, and the flyout opens on hover or focus as it always
54
+ * does. Without an `href` the whole row is the toggle, as before.
55
+ */
56
+ href?: string;
45
57
  /** Nested items — links, sections, or further groups. */
46
58
  items: SidebarItem[];
47
59
  /**
@@ -38,12 +38,9 @@
38
38
  </template>
39
39
 
40
40
  <script setup lang="ts">
41
- import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
41
+ import { ref } from 'vue';
42
42
  import { generateId } from '../../../utils/a11y/id.js';
43
- import {
44
- mountDialog,
45
- type DialogControl,
46
- } from '../../../utils/controllers/dialog.js';
43
+ import { useDialog } from '../../../composables/useDialog.js';
47
44
 
48
45
  defineOptions({ inheritAttrs: false });
49
46
 
@@ -72,7 +69,6 @@ const emit = defineEmits<{
72
69
  const rootRef = ref<HTMLDialogElement | null>(null);
73
70
  const titleId = generateId('alertdialog-title');
74
71
  const descriptionId = generateId('alertdialog-desc');
75
- let control: DialogControl | null = null;
76
72
 
77
73
  function onConfirm(): void {
78
74
  emit('confirm');
@@ -84,31 +80,14 @@ function onCancel(): void {
84
80
  emit('update:open', false);
85
81
  }
86
82
 
87
- onMounted(() => {
88
- if (!rootRef.value) return;
89
- control = mountDialog(rootRef.value, {
90
- role: 'alertdialog',
91
- persistent: true,
92
- initialFocus: 'cancel',
93
- // No onClose persistent dialogs suppress Escape and backdrop-click
94
- // dismissal at the controller layer. Close happens only through the
95
- // explicit confirm/cancel buttons wired in the template.
96
- });
97
- if (props.open) control.open();
98
- });
99
-
100
- watch(
101
- () => props.open,
102
- (isOpen) => {
103
- if (!control) return;
104
- if (isOpen) control.open();
105
- else control.close();
106
- },
107
- );
108
-
109
- onBeforeUnmount(() => {
110
- control?.teardown();
111
- control = null;
83
+ useDialog(rootRef, {
84
+ role: 'alertdialog',
85
+ persistent: true,
86
+ initialFocus: 'cancel',
87
+ open: () => props.open,
88
+ // No onClose — persistent dialogs suppress Escape and backdrop-click
89
+ // dismissal at the composable layer. Close happens only through the
90
+ // explicit confirm/cancel buttons wired in the template.
112
91
  });
113
92
  </script>
114
93