@pienter/ui 0.2.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 (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
@@ -0,0 +1,53 @@
1
+ @layer components {
2
+ .pui-pagination {
3
+ display: inline-flex;
4
+ align-items: center;
5
+ gap: var(--space-3xs);
6
+ list-style: none;
7
+ padding: 0;
8
+ margin: 0;
9
+ }
10
+
11
+ .pui-pagination__item {
12
+ appearance: none;
13
+ border: var(--stroke-sm) solid var(--border-clr-base);
14
+ background: var(--bg-clr-surface);
15
+ font: inherit;
16
+ font-size: var(--step--1);
17
+ font-weight: var(--fw-semibold);
18
+ min-width: 2.2rem;
19
+ height: 2.2rem;
20
+ display: inline-grid;
21
+ place-items: center;
22
+ cursor: pointer;
23
+ border-radius: var(--radius-sm);
24
+ text-decoration: none;
25
+ color: var(--text-clr-base);
26
+ }
27
+
28
+ .pui-pagination__item:hover {
29
+ background: var(--bg-clr-surface-2);
30
+ }
31
+
32
+ .pui-pagination__item:focus-visible {
33
+ outline: 3px solid var(--outline-clr-base);
34
+ outline-offset: 2px;
35
+ }
36
+
37
+ .pui-pagination__item[aria-current='page'] {
38
+ background: var(--bg-clr-dark);
39
+ color: var(--text-clr-inverse);
40
+ border-color: var(--border-clr-strong);
41
+ }
42
+
43
+ .pui-pagination__item:disabled,
44
+ .pui-pagination__item[aria-disabled='true'] {
45
+ opacity: 0.4;
46
+ cursor: not-allowed;
47
+ }
48
+
49
+ .pui-pagination__ellipsis {
50
+ color: var(--text-clr-muted);
51
+ padding-inline: var(--space-2xs);
52
+ }
53
+ }
@@ -0,0 +1,132 @@
1
+ ---
2
+ import './sidebar.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+ import { deriveGroupKey } from '../../../utils/controllers/sidebar.js';
5
+ import SidebarItemRender from './SidebarItemRender.astro';
6
+ import type { SidebarItem } from './types.js';
7
+
8
+ interface Props {
9
+ id?: string;
10
+ items: SidebarItem[];
11
+ activeHref?: string | null;
12
+ collapsed?: boolean;
13
+ drawerOpen?: boolean;
14
+ expandedGroups?: string[];
15
+ ariaLabel?: string;
16
+ drawerBreakpoint?: number;
17
+ }
18
+
19
+ const {
20
+ id,
21
+ items,
22
+ activeHref = null,
23
+ collapsed = false,
24
+ drawerOpen = false,
25
+ expandedGroups,
26
+ ariaLabel = 'Main navigation',
27
+ drawerBreakpoint = 768,
28
+ ...rest
29
+ } = Astro.props;
30
+
31
+ const sidebarId = id ?? generateId('pui-sidebar');
32
+
33
+ // Pre-compute the initial expanded-groups set as a JSON string so the
34
+ // mount script can hydrate `mountSidebar` with it.
35
+ const initialExpanded = expandedGroups
36
+ ? JSON.stringify(expandedGroups)
37
+ : JSON.stringify(
38
+ // Fall back to defaultOpen=true groups derived from item shape.
39
+ collectDefaultOpenKeys(items),
40
+ );
41
+
42
+ function collectDefaultOpenKeys(arr: SidebarItem[]): string[] {
43
+ const keys: string[] = [];
44
+ arr.forEach((it) => {
45
+ if (it.type === 'group') {
46
+ if (it.defaultOpen) keys.push(it.key ?? deriveGroupKey(it.label));
47
+ keys.push(...collectDefaultOpenKeys(it.items));
48
+ }
49
+ });
50
+ return keys;
51
+ }
52
+ ---
53
+
54
+ <nav
55
+ id={sidebarId}
56
+ class="pui-sidebar"
57
+ data-pui-sidebar
58
+ data-state={collapsed ? 'collapsed' : 'expanded'}
59
+ data-drawer-state="closed"
60
+ aria-label={ariaLabel}
61
+ data-collapsed-initially={collapsed ? 'true' : undefined}
62
+ data-drawer-open-initially={drawerOpen ? 'true' : undefined}
63
+ data-active-href={activeHref ?? undefined}
64
+ data-drawer-breakpoint={String(drawerBreakpoint)}
65
+ data-expanded-groups={initialExpanded}
66
+ {...rest}
67
+ >
68
+ <div class="pui-sidebar__backdrop" data-sidebar-backdrop aria-hidden="true">
69
+ </div>
70
+ <div class="pui-sidebar__panel" data-sidebar-panel>
71
+ {
72
+ Astro.slots.has('brand') && (
73
+ <div class="pui-sidebar__brand">
74
+ <slot name="brand" />
75
+ </div>
76
+ )
77
+ }
78
+
79
+ <ul class="pui-sidebar__menu" data-sidebar-menu>
80
+ {items.map((item) => <SidebarItemRender item={item} />)}
81
+ </ul>
82
+
83
+ {
84
+ Astro.slots.has('footer') && (
85
+ <div class="pui-sidebar__footer">
86
+ <slot name="footer" />
87
+ </div>
88
+ )
89
+ }
90
+ </div>
91
+ </nav>
92
+
93
+ <script>
94
+ import { mountSidebar } from '@pienter/ui/utils/controllers/sidebar.js';
95
+
96
+ document.querySelectorAll<HTMLElement>('[data-pui-sidebar]').forEach((el) => {
97
+ const node = el as HTMLElement & {
98
+ _sidebar?: ReturnType<typeof mountSidebar>;
99
+ };
100
+ if (node._sidebar) return; // idempotent — guard against double-mounts
101
+
102
+ const collapsed = el.dataset.collapsedInitially === 'true';
103
+ const drawerOpenInitially = el.dataset.drawerOpenInitially === 'true';
104
+ const activeHref = el.dataset.activeHref ?? null;
105
+ const drawerBreakpoint = Number(el.dataset.drawerBreakpoint ?? '768');
106
+ let expandedGroups: string[];
107
+ try {
108
+ expandedGroups = JSON.parse(el.dataset.expandedGroups ?? '[]');
109
+ } catch {
110
+ expandedGroups = [];
111
+ }
112
+
113
+ const control = mountSidebar(el, {
114
+ collapsed,
115
+ drawerBreakpoint,
116
+ expandedGroups,
117
+ activeHref,
118
+ // Vue mirrors `onDrawerOpenChange` into a reactive prop whose
119
+ // watcher then calls openDrawer()/closeDrawer(). Astro has no
120
+ // reactivity, so the mount script wires the callback directly to
121
+ // the imperative API. Without this, backdrop click and Escape —
122
+ // both of which emit `onDrawerOpenChange(false)` from the
123
+ // controller — are no-ops in Astro.
124
+ onDrawerOpenChange: (open) => {
125
+ if (open) control.openDrawer();
126
+ else control.closeDrawer();
127
+ },
128
+ });
129
+ node._sidebar = control;
130
+ if (drawerOpenInitially) control.openDrawer();
131
+ });
132
+ </script>
@@ -0,0 +1,174 @@
1
+ <template>
2
+ <nav
3
+ ref="rootRef"
4
+ class="pui-sidebar"
5
+ data-pui-sidebar
6
+ :data-state="collapsed ? 'collapsed' : 'expanded'"
7
+ data-drawer-state="closed"
8
+ :aria-label="ariaLabel"
9
+ >
10
+ <div
11
+ class="pui-sidebar__backdrop"
12
+ data-sidebar-backdrop
13
+ aria-hidden="true"
14
+ />
15
+ <div class="pui-sidebar__panel" data-sidebar-panel>
16
+ <div v-if="$slots.brand" class="pui-sidebar__brand">
17
+ <slot name="brand" />
18
+ </div>
19
+
20
+ <ul class="pui-sidebar__menu" data-sidebar-menu>
21
+ <SidebarItemRender
22
+ v-for="(item, idx) in items"
23
+ :key="itemKey(item, idx)"
24
+ :item="item"
25
+ />
26
+ </ul>
27
+
28
+ <div v-if="$slots.footer" class="pui-sidebar__footer">
29
+ <slot name="footer" />
30
+ </div>
31
+ </div>
32
+ </nav>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
37
+ import {
38
+ mountSidebar,
39
+ deriveGroupKey,
40
+ type SidebarControl,
41
+ } from '../../../utils/controllers/sidebar.js';
42
+ import SidebarItemRender from './SidebarItemRender.vue';
43
+ import type { SidebarItem } from './types.js';
44
+
45
+ const props = withDefaults(
46
+ defineProps<{
47
+ items: SidebarItem[];
48
+ activeHref?: string | null;
49
+ collapsed?: boolean;
50
+ 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
+ */
57
+ expandedGroups?: string[];
58
+ ariaLabel?: string;
59
+ drawerBreakpoint?: number;
60
+ }>(),
61
+ {
62
+ activeHref: null,
63
+ collapsed: false,
64
+ 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.
68
+ expandedGroups: undefined,
69
+ ariaLabel: 'Main navigation',
70
+ drawerBreakpoint: 768,
71
+ },
72
+ );
73
+
74
+ const emit = defineEmits<{
75
+ 'update:collapsed': [value: boolean];
76
+ 'update:drawerOpen': [value: boolean];
77
+ 'update:expandedGroups': [value: string[]];
78
+ }>();
79
+
80
+ const rootRef = ref<HTMLElement | null>(null);
81
+ let control: SidebarControl | null = null;
82
+
83
+ function itemKey(item: SidebarItem, idx: number): string {
84
+ if (item.type === 'link') return `link:${item.href}`;
85
+ if (item.type === 'group')
86
+ return `group:${item.key ?? deriveGroupKey(item.label)}`;
87
+ return `section:${item.label}:${idx}`;
88
+ }
89
+
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
+ });
108
+
109
+ watch(
110
+ () => props.collapsed,
111
+ (v) => {
112
+ if (!control) return;
113
+ if (v) control.collapse();
114
+ else control.expand();
115
+ },
116
+ );
117
+
118
+ watch(
119
+ () => props.drawerOpen,
120
+ (v) => {
121
+ if (!control) return;
122
+ if (v) control.openDrawer();
123
+ else control.closeDrawer();
124
+ },
125
+ );
126
+
127
+ watch(
128
+ () => props.activeHref,
129
+ (v) => {
130
+ control?.setActiveHref(v);
131
+ },
132
+ );
133
+
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
+ });
152
+ },
153
+ { deep: true },
154
+ );
155
+
156
+ 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));
162
+ },
163
+ { deep: true },
164
+ );
165
+
166
+ onBeforeUnmount(() => {
167
+ control?.teardown();
168
+ control = null;
169
+ });
170
+ </script>
171
+
172
+ <style>
173
+ @import './sidebar.css';
174
+ </style>
@@ -0,0 +1,83 @@
1
+ ---
2
+ import Icon from '../../display/icon/Icon.astro';
3
+ import type { IconName } from '../../../icons/index.js';
4
+ import { generateId } from '../../../utils/a11y/id.js';
5
+ import { deriveGroupKey } from '../../../utils/controllers/sidebar.js';
6
+ import type { SidebarItem } from './types.js';
7
+
8
+ interface Props {
9
+ item: SidebarItem;
10
+ }
11
+
12
+ const { item } = Astro.props;
13
+ const itemsId = generateId('pui-sidebar-group');
14
+ const resolvedKey =
15
+ item.type === 'group' ? (item.key ?? deriveGroupKey(item.label)) : '';
16
+ ---
17
+
18
+ {
19
+ item.type === 'link' && (
20
+ <li class="pui-sidebar__item">
21
+ <a
22
+ class="pui-sidebar__link"
23
+ data-sidebar-menu-item
24
+ data-sidebar-link
25
+ href={item.href}
26
+ data-disabled={item.disabled ? 'true' : undefined}
27
+ aria-disabled={item.disabled ? 'true' : undefined}
28
+ tabindex="-1"
29
+ >
30
+ {item.icon && <Icon name={item.icon as IconName} size="sm" />}
31
+ <span class="pui-sidebar__link-label">{item.label}</span>
32
+ {item.badge !== undefined && (
33
+ <span class="pui-sidebar__badge">{item.badge}</span>
34
+ )}
35
+ </a>
36
+ </li>
37
+ )
38
+ }
39
+
40
+ {
41
+ item.type === 'group' && (
42
+ <li
43
+ class="pui-sidebar__group"
44
+ data-sidebar-group
45
+ data-group-key={resolvedKey}
46
+ data-default-open={item.defaultOpen ? 'true' : undefined}
47
+ data-state="collapsed"
48
+ >
49
+ <button
50
+ type="button"
51
+ class="pui-sidebar__group-toggle"
52
+ data-sidebar-menu-item
53
+ data-sidebar-group-toggle
54
+ aria-expanded="false"
55
+ aria-controls={itemsId}
56
+ tabindex="-1"
57
+ >
58
+ {item.icon && <Icon name={item.icon as IconName} size="sm" />}
59
+ <span class="pui-sidebar__link-label">{item.label}</span>
60
+ <Icon name="chevron-down" size="sm" class="pui-sidebar__group-caret" />
61
+ </button>
62
+ <ul
63
+ id={itemsId}
64
+ class="pui-sidebar__group-items"
65
+ data-sidebar-group-items
66
+ role="group"
67
+ hidden
68
+ >
69
+ {item.items.map((child) => (
70
+ <Astro.self item={child} />
71
+ ))}
72
+ </ul>
73
+ </li>
74
+ )
75
+ }
76
+
77
+ {
78
+ item.type === 'section' && (
79
+ <li class="pui-sidebar__section" role="presentation">
80
+ <span class="pui-sidebar__section-label">{item.label}</span>
81
+ </li>
82
+ )
83
+ }
@@ -0,0 +1,98 @@
1
+ <template>
2
+ <!-- link -->
3
+ <li v-if="item.type === 'link'" class="pui-sidebar__item">
4
+ <a
5
+ class="pui-sidebar__link"
6
+ data-sidebar-menu-item
7
+ data-sidebar-link
8
+ :href="item.href"
9
+ :data-disabled="item.disabled ? 'true' : undefined"
10
+ :aria-disabled="item.disabled ? 'true' : undefined"
11
+ tabindex="-1"
12
+ >
13
+ <Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
14
+ <span class="pui-sidebar__link-label">{{ item.label }}</span>
15
+ <span v-if="item.badge !== undefined" class="pui-sidebar__badge">{{
16
+ item.badge
17
+ }}</span>
18
+ </a>
19
+ </li>
20
+
21
+ <!-- group -->
22
+ <li
23
+ v-else-if="item.type === 'group'"
24
+ class="pui-sidebar__group"
25
+ data-sidebar-group
26
+ :data-group-key="resolvedKey"
27
+ :data-default-open="item.defaultOpen ? 'true' : undefined"
28
+ data-state="collapsed"
29
+ >
30
+ <button
31
+ type="button"
32
+ class="pui-sidebar__group-toggle"
33
+ data-sidebar-menu-item
34
+ data-sidebar-group-toggle
35
+ :aria-expanded="'false'"
36
+ :aria-controls="itemsId"
37
+ tabindex="-1"
38
+ >
39
+ <Icon v-if="item.icon" :name="item.icon as IconName" size="sm" />
40
+ <span class="pui-sidebar__link-label">{{ item.label }}</span>
41
+ <Icon
42
+ name="chevron-down"
43
+ size="sm"
44
+ class="pui-sidebar__group-caret"
45
+ />
46
+ </button>
47
+ <ul
48
+ :id="itemsId"
49
+ class="pui-sidebar__group-items"
50
+ data-sidebar-group-items
51
+ role="group"
52
+ hidden
53
+ >
54
+ <SidebarItemRender
55
+ v-for="(child, idx) in item.items"
56
+ :key="childKey(child, idx)"
57
+ :item="child"
58
+ />
59
+ </ul>
60
+ </li>
61
+
62
+ <!-- section -->
63
+ <li
64
+ v-else-if="item.type === 'section'"
65
+ class="pui-sidebar__section"
66
+ role="presentation"
67
+ >
68
+ <span class="pui-sidebar__section-label">{{ item.label }}</span>
69
+ </li>
70
+ </template>
71
+
72
+ <script setup lang="ts">
73
+ import { computed } from 'vue';
74
+ import Icon from '../../display/icon/Icon.vue';
75
+ import type { IconName } from '../../../icons/index.js';
76
+ import { generateId } from '../../../utils/a11y/id.js';
77
+ import { deriveGroupKey } from '../../../utils/controllers/sidebar.js';
78
+ import type { SidebarItem } from './types.js';
79
+
80
+ const props = defineProps<{ item: SidebarItem }>();
81
+
82
+ // Stable id for the group's child list (linked from `aria-controls` on
83
+ // the toggle). Generated once per render of this component instance.
84
+ const itemsId = generateId('pui-sidebar-group');
85
+
86
+ const resolvedKey = computed(() =>
87
+ props.item.type === 'group'
88
+ ? (props.item.key ?? deriveGroupKey(props.item.label))
89
+ : '',
90
+ );
91
+
92
+ function childKey(child: SidebarItem, idx: number): string {
93
+ if (child.type === 'link') return `link:${child.href}`;
94
+ if (child.type === 'group')
95
+ return `group:${child.key ?? deriveGroupKey(child.label)}`;
96
+ return `section:${child.label}:${idx}`;
97
+ }
98
+ </script>