@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,303 @@
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
+ * ============================================================ */
8
+ .pui-sidebar {
9
+ --pui-sidebar-width: var(--space-sidebar-width);
10
+ --pui-sidebar-width-collapsed: var(--space-sidebar-width-collapsed);
11
+
12
+ display: block;
13
+ width: var(--pui-sidebar-width);
14
+ flex-shrink: 0;
15
+ background: var(--bg-clr-surface);
16
+ border-inline-end: var(--stroke-sm) solid var(--border-clr-base);
17
+ transition: width 180ms ease;
18
+ }
19
+
20
+ .pui-sidebar[data-state='collapsed'] {
21
+ width: var(--pui-sidebar-width-collapsed);
22
+ }
23
+
24
+ /* ============================================================
25
+ * Panel — the visible column / drawer body containing brand, menu,
26
+ * footer.
27
+ * ============================================================ */
28
+ .pui-sidebar__panel {
29
+ display: flex;
30
+ flex-direction: column;
31
+ height: 100%;
32
+ min-height: 100%;
33
+ padding: var(--space-s);
34
+ gap: var(--space-s);
35
+ background: var(--bg-clr-surface);
36
+ }
37
+
38
+ /* ============================================================
39
+ * Backdrop — only meaningful in mobile drawer mode. Hidden on desktop.
40
+ * ============================================================ */
41
+ .pui-sidebar__backdrop {
42
+ display: none;
43
+ }
44
+
45
+ /* ============================================================
46
+ * Brand region — top of the panel. Layout owned by consumer markup
47
+ * inside the slot; we just provide consistent spacing.
48
+ * ============================================================ */
49
+ .pui-sidebar__brand {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: var(--space-2xs);
53
+ padding-block: var(--space-2xs);
54
+ }
55
+
56
+ /* Hide brand inner text when collapsed; consumer markup with
57
+ * `data-keep-when-collapsed` survives. */
58
+ .pui-sidebar[data-state='collapsed']
59
+ .pui-sidebar__brand
60
+ > :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. */
64
+ }
65
+
66
+ /* ============================================================
67
+ * Menu list — the data-driven menu region.
68
+ * ============================================================ */
69
+ .pui-sidebar__menu {
70
+ list-style: none;
71
+ margin: 0;
72
+ padding: 0;
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: var(--space-3xs);
76
+ flex: 1;
77
+ min-height: 0;
78
+ overflow-y: auto;
79
+ }
80
+
81
+ .pui-sidebar__item {
82
+ list-style: none;
83
+ margin: 0;
84
+ padding: 0;
85
+ }
86
+
87
+ /* ============================================================
88
+ * Link / group-toggle — share visual treatment so the menu reads
89
+ * as one column of equally-weighted entries.
90
+ * ============================================================ */
91
+ .pui-sidebar__link,
92
+ .pui-sidebar__group-toggle {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: var(--space-2xs);
96
+ width: 100%;
97
+ padding: var(--space-2xs) var(--space-xs);
98
+ border-radius: var(--radius-sm);
99
+ background: transparent;
100
+ border: 0;
101
+ color: var(--text-clr-base);
102
+ text-decoration: none;
103
+ font: inherit;
104
+ font-size: var(--step--1);
105
+ font-weight: var(--fw-semibold);
106
+ text-align: start;
107
+ cursor: pointer;
108
+ }
109
+
110
+ .pui-sidebar__link:hover,
111
+ .pui-sidebar__group-toggle:hover {
112
+ background: var(--bg-clr-accent);
113
+ color: var(--text-clr-brand);
114
+ }
115
+
116
+ .pui-sidebar__link[aria-current='page'] {
117
+ background: var(--bg-clr-accent);
118
+ color: var(--text-clr-brand-selected);
119
+ }
120
+
121
+ .pui-sidebar__link[aria-disabled='true'] {
122
+ color: var(--text-clr-muted);
123
+ pointer-events: none;
124
+ }
125
+
126
+ .pui-sidebar__link:focus-visible,
127
+ .pui-sidebar__group-toggle:focus-visible {
128
+ outline: 3px solid var(--outline-clr-base);
129
+ outline-offset: 2px;
130
+ }
131
+
132
+ .pui-sidebar__link-label {
133
+ flex: 1;
134
+ min-width: 0;
135
+ overflow: hidden;
136
+ text-overflow: ellipsis;
137
+ white-space: nowrap;
138
+ }
139
+
140
+ .pui-sidebar__badge {
141
+ margin-inline-start: auto;
142
+ padding-inline: var(--space-3xs);
143
+ border-radius: var(--radius-pill);
144
+ background: var(--bg-clr-brand);
145
+ color: var(--bg-clr-brand-ink);
146
+ font-size: var(--step--2);
147
+ font-weight: var(--fw-bold);
148
+ line-height: 1.4;
149
+ min-width: 1.25rem;
150
+ text-align: center;
151
+ }
152
+
153
+ /* ============================================================
154
+ * Group — collapsible container.
155
+ * ============================================================ */
156
+ .pui-sidebar__group {
157
+ list-style: none;
158
+ margin: 0;
159
+ padding: 0;
160
+ }
161
+
162
+ .pui-sidebar__group-caret {
163
+ margin-inline-start: auto;
164
+ transition: transform 150ms ease;
165
+ }
166
+
167
+ .pui-sidebar__group-toggle[aria-expanded='true'] .pui-sidebar__group-caret {
168
+ transform: rotate(180deg);
169
+ }
170
+
171
+ .pui-sidebar__group[data-active='true'] > .pui-sidebar__group-toggle {
172
+ color: var(--text-clr-brand-selected);
173
+ }
174
+
175
+ .pui-sidebar__group-items {
176
+ list-style: none;
177
+ margin: 0;
178
+ padding: 0;
179
+ padding-inline-start: var(--space-m);
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: var(--space-3xs);
183
+ }
184
+
185
+ .pui-sidebar__group-items[hidden] {
186
+ display: none;
187
+ }
188
+
189
+ /* ============================================================
190
+ * Section header — non-interactive label.
191
+ * ============================================================ */
192
+ .pui-sidebar__section {
193
+ list-style: none;
194
+ margin: 0;
195
+ padding: var(--space-s) var(--space-xs) var(--space-3xs);
196
+ }
197
+
198
+ .pui-sidebar__section-label {
199
+ font-family: var(--ff-mono);
200
+ font-size: var(--step--2);
201
+ text-transform: uppercase;
202
+ letter-spacing: var(--ls-eyebrow);
203
+ color: var(--text-clr-muted);
204
+ }
205
+
206
+ /* ============================================================
207
+ * Footer region — bottom of the panel.
208
+ * ============================================================ */
209
+ .pui-sidebar__footer {
210
+ margin-block-start: auto;
211
+ padding-block-start: var(--space-2xs);
212
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
213
+ }
214
+
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;
224
+ }
225
+
226
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__link,
227
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-toggle {
228
+ justify-content: center;
229
+ padding-inline: var(--space-3xs);
230
+ }
231
+
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 {
236
+ display: none;
237
+ }
238
+
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
+ * ============================================================ */
246
+ @media (max-width: 767px) {
247
+ .pui-sidebar {
248
+ width: 0;
249
+ border-inline-end: none;
250
+ transition: none;
251
+ }
252
+
253
+ .pui-sidebar__panel {
254
+ position: fixed;
255
+ inset-block: 0;
256
+ inset-inline-start: 0;
257
+ width: min(20rem, 80vw);
258
+ box-shadow: var(--shadow-spark-lg);
259
+ transform: translateX(-100%);
260
+ transition: transform 220ms ease;
261
+ z-index: 50;
262
+ }
263
+
264
+ .pui-sidebar[data-drawer-state='open'] .pui-sidebar__panel {
265
+ transform: translateX(0);
266
+ }
267
+
268
+ .pui-sidebar__backdrop {
269
+ display: block;
270
+ position: fixed;
271
+ inset: 0;
272
+ background: var(--bg-clr-backdrop);
273
+ opacity: 0;
274
+ pointer-events: none;
275
+ transition: opacity 220ms ease;
276
+ z-index: 40;
277
+ }
278
+
279
+ .pui-sidebar[data-drawer-state='open'] .pui-sidebar__backdrop {
280
+ opacity: 1;
281
+ pointer-events: auto;
282
+ }
283
+
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 {
290
+ display: revert;
291
+ }
292
+
293
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__link,
294
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-toggle {
295
+ justify-content: flex-start;
296
+ padding-inline: var(--space-xs);
297
+ }
298
+
299
+ .pui-sidebar[data-state='collapsed'] .pui-sidebar__group-items {
300
+ display: flex;
301
+ }
302
+ }
303
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Sidebar driver-data discriminated union.
3
+ *
4
+ * The Sidebar component takes an `:items` prop of `SidebarItem[]`. Each
5
+ * entry is one of three shapes, distinguished by the `type` field:
6
+ *
7
+ * - `link` — a navigable menu entry (renders as `<a>`)
8
+ * - `group` — an expandable container with nested items
9
+ * - `section` — a non-interactive section header / divider with a label
10
+ *
11
+ * Active-state highlighting is derived from the Sidebar's `:active-href`
12
+ * prop using the hierarchical-match algorithm documented in
13
+ * `packages/ui/CONVENTIONS.md`. Consumers do NOT mark items active.
14
+ *
15
+ * See the Sidebar audit in `./AUDIT.md` for the
16
+ * full hybrid-API archetype rules.
17
+ */
18
+
19
+ /** A navigable menu entry. */
20
+ export interface SidebarLink {
21
+ type: 'link';
22
+ /** Visible label. */
23
+ label: string;
24
+ /** Target href; matched against the Sidebar's `activeHref` prop. */
25
+ href: string;
26
+ /**
27
+ * Optional icon name from the @pienter/ui icon set. Rendered as a
28
+ * leading icon. v1 accepts a string (icon name); may grow to
29
+ * `string | Component` if rich icon shapes become a need.
30
+ */
31
+ icon?: string;
32
+ /** Optional trailing badge (e.g. unread count or "New"). */
33
+ badge?: string | number;
34
+ /** Disabled items are non-interactive and skipped by keyboard nav. */
35
+ disabled?: boolean;
36
+ }
37
+
38
+ /** An expandable container with nested items. */
39
+ export interface SidebarGroup {
40
+ type: 'group';
41
+ /** Visible label on the group toggle button. */
42
+ label: string;
43
+ /** Optional leading icon on the group toggle. */
44
+ icon?: string;
45
+ /** Nested items — links, sections, or further groups. */
46
+ items: SidebarItem[];
47
+ /**
48
+ * Whether the group starts expanded on first mount. Ignored after
49
+ * the consumer has explicit control of `expandedGroups`. Default
50
+ * `false`.
51
+ */
52
+ defaultOpen?: boolean;
53
+ /**
54
+ * Optional explicit key used by the controller's `expandGroup` /
55
+ * `collapseGroup` methods and by the Sidebar's `expandedGroups`
56
+ * prop. When omitted, the key is derived from `label` via
57
+ * kebab-case slugification (e.g. "Member admin" → "member-admin").
58
+ * REQUIRED when multiple groups share a label, otherwise their
59
+ * derived keys would collide.
60
+ */
61
+ key?: string;
62
+ }
63
+
64
+ /** A non-interactive section header / divider with a label. */
65
+ export interface SidebarSection {
66
+ type: 'section';
67
+ /** Visible label rendered as a small caption. */
68
+ label: string;
69
+ }
70
+
71
+ /** Discriminated union over all sidebar item shapes. */
72
+ export type SidebarItem = SidebarLink | SidebarGroup | SidebarSection;
@@ -0,0 +1,84 @@
1
+ <template>
2
+ <div class="pui-tabs" role="tablist">
3
+ <button
4
+ v-for="tab in tabs"
5
+ :key="tab.key"
6
+ type="button"
7
+ class="pui-tabs__item"
8
+ role="tab"
9
+ :aria-selected="modelValue === tab.key ? 'true' : 'false'"
10
+ :tabindex="modelValue === tab.key ? 0 : -1"
11
+ :disabled="tab.disabled || undefined"
12
+ @click="emit('update:modelValue', tab.key)"
13
+ @keydown="handleKeydown($event)"
14
+ >
15
+ {{ tab.label }}
16
+ </button>
17
+ </div>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ interface TabItem {
22
+ key: string;
23
+ label: string;
24
+ disabled?: boolean;
25
+ }
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ modelValue?: string;
30
+ tabs: TabItem[];
31
+ }>(),
32
+ {
33
+ modelValue: undefined,
34
+ },
35
+ );
36
+
37
+ const emit = defineEmits<{
38
+ 'update:modelValue': [value: string];
39
+ }>();
40
+
41
+ function handleKeydown(event: KeyboardEvent): void {
42
+ const target = event.currentTarget as HTMLElement;
43
+ const items = Array.from(
44
+ target.parentElement!.querySelectorAll<HTMLElement>(
45
+ '.pui-tabs__item:not([disabled])',
46
+ ),
47
+ );
48
+ if (items.length === 0) return;
49
+ const currentIndex = items.indexOf(target);
50
+ let nextIndex: number;
51
+
52
+ switch (event.key) {
53
+ case 'ArrowRight':
54
+ nextIndex = (currentIndex + 1) % items.length;
55
+ break;
56
+ case 'ArrowLeft':
57
+ nextIndex = (currentIndex - 1 + items.length) % items.length;
58
+ break;
59
+ case 'Home':
60
+ nextIndex = 0;
61
+ break;
62
+ case 'End':
63
+ nextIndex = items.length - 1;
64
+ break;
65
+ default:
66
+ return;
67
+ }
68
+
69
+ event.preventDefault();
70
+ // Disabled tabs are excluded from both `items` (`:not([disabled])`) and
71
+ // `enabledTabs`, so the indices line up.
72
+ const enabledTabs = props.tabs.filter((t) => !t.disabled);
73
+ const nextEl = items[nextIndex];
74
+ const nextTab = enabledTabs[nextIndex];
75
+ if (nextEl && nextTab) {
76
+ nextEl.focus();
77
+ emit('update:modelValue', nextTab.key);
78
+ }
79
+ }
80
+ </script>
81
+
82
+ <style>
83
+ @import './tabs.css';
84
+ </style>
@@ -0,0 +1,39 @@
1
+ @layer components {
2
+ .pui-tabs {
3
+ display: flex;
4
+ gap: var(--space-s);
5
+ border-block-end: var(--stroke-sm) solid var(--border-clr-base);
6
+ }
7
+ .pui-tabs__item {
8
+ appearance: none;
9
+ background: transparent;
10
+ border: 0;
11
+ padding: var(--space-2xs) 0;
12
+ font: inherit;
13
+ font-size: var(--step--1);
14
+ font-weight: var(--fw-semibold);
15
+ color: var(--text-clr-muted);
16
+ cursor: pointer;
17
+ border-block-end: 2px solid transparent;
18
+ margin-block-end: -1px;
19
+ transition: color 0.12s ease;
20
+ }
21
+ .pui-tabs__item[aria-selected='true'] {
22
+ color: var(--text-clr-base);
23
+ border-block-end-color: var(--border-clr-brand);
24
+ }
25
+ .pui-tabs__item:hover {
26
+ color: var(--text-clr-base);
27
+ }
28
+ .pui-tabs__item:focus-visible {
29
+ outline: 3px solid var(--outline-clr-base);
30
+ outline-offset: 2px;
31
+ }
32
+ .pui-tabs__item:disabled {
33
+ opacity: 0.4;
34
+ cursor: not-allowed;
35
+ }
36
+ .pui-tabs__item:disabled:hover {
37
+ color: var(--text-clr-muted);
38
+ }
39
+ }
@@ -0,0 +1,112 @@
1
+ ---
2
+ import './alert-dialog.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ open?: boolean;
8
+ title: string;
9
+ description: string;
10
+ confirmLabel?: string;
11
+ cancelLabel?: string;
12
+ destructive?: boolean;
13
+ }
14
+
15
+ const {
16
+ id,
17
+ open = false,
18
+ title,
19
+ description,
20
+ confirmLabel = 'Confirm',
21
+ cancelLabel = 'Cancel',
22
+ destructive = false,
23
+ ...rest
24
+ } = Astro.props;
25
+
26
+ const dialogId = id ?? generateId('alertdialog');
27
+ const titleId = `${dialogId}-title`;
28
+ const descriptionId = `${dialogId}-desc`;
29
+ ---
30
+
31
+ <dialog
32
+ id={dialogId}
33
+ class="pui-alert-dialog"
34
+ data-state="closed"
35
+ data-pui-alert-dialog
36
+ data-open-initially={open ? 'true' : undefined}
37
+ aria-labelledby={titleId}
38
+ aria-describedby={descriptionId}
39
+ {...rest}
40
+ >
41
+ <div class="pui-alert-dialog__head">
42
+ <h2 id={titleId} class="pui-alert-dialog__title">{title}</h2>
43
+ <p id={descriptionId} class="pui-alert-dialog__description">
44
+ {description}
45
+ </p>
46
+ </div>
47
+ <div class="pui-alert-dialog__actions">
48
+ <button
49
+ type="button"
50
+ class="pui-btn"
51
+ data-size="md"
52
+ data-dialog-cancel
53
+ data-alert-dialog-action="cancel">{cancelLabel}</button
54
+ >
55
+ <button
56
+ type="button"
57
+ class="pui-btn"
58
+ data-variant={destructive ? 'danger' : 'primary'}
59
+ data-size="md"
60
+ data-alert-dialog-action="confirm">{confirmLabel}</button
61
+ >
62
+ </div>
63
+ </dialog>
64
+
65
+ <script>
66
+ import { mountDialog } from '@pienter/ui/utils/controllers/dialog.js';
67
+
68
+ // AlertDialog uses its own selector marker (`data-pui-alert-dialog`) so the
69
+ // mount block can hard-code the locked alertdialog config (role,
70
+ // persistent, initialFocus: 'cancel') without branching on data-attrs.
71
+ document
72
+ .querySelectorAll<HTMLDialogElement>('dialog[data-pui-alert-dialog]')
73
+ .forEach((el) => {
74
+ // Idempotent — guard against double-mounts (HMR, multiple <AlertDialog> imports).
75
+ if ((el as unknown as { _dialog?: unknown })._dialog) return;
76
+ const control = mountDialog(el, {
77
+ role: 'alertdialog',
78
+ persistent: true,
79
+ initialFocus: 'cancel',
80
+ });
81
+ (el as unknown as { _dialog: typeof control })._dialog = control;
82
+ if (el.dataset.openInitially === 'true') control.open();
83
+ });
84
+
85
+ // Event-delegated confirm/cancel button handlers. Dispatching custom
86
+ // `confirm` / `cancel` events on the dialog lets Astro consumers wire
87
+ // callbacks via `myDialog.addEventListener('confirm', ...)` without
88
+ // touching the controller. The controller's close() is invoked here so
89
+ // consumers don't have to repeat the boilerplate at every call-site.
90
+ document.addEventListener('click', (e) => {
91
+ const target = e.target as HTMLElement | null;
92
+ if (!target) return;
93
+ const action = target.closest<HTMLElement>('[data-alert-dialog-action]');
94
+ if (!action) return;
95
+ const dialog = action.closest<HTMLDialogElement>(
96
+ 'dialog[data-pui-alert-dialog]',
97
+ );
98
+ if (!dialog) return;
99
+ const actionName = action.getAttribute('data-alert-dialog-action');
100
+ const detail = { dialogId: dialog.id };
101
+ if (actionName === 'confirm') {
102
+ dialog.dispatchEvent(
103
+ new CustomEvent('confirm', { bubbles: true, detail }),
104
+ );
105
+ } else if (actionName === 'cancel') {
106
+ dialog.dispatchEvent(
107
+ new CustomEvent('cancel', { bubbles: true, detail }),
108
+ );
109
+ }
110
+ (dialog as unknown as { _dialog?: { close(): void } })._dialog?.close();
111
+ });
112
+ </script>