@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,76 @@
1
+ @layer components {
2
+ /* ============================================================
3
+ * Block — the native <details> element. The browser handles
4
+ * expand/collapse via the [open] attribute; we layer styling.
5
+ * ============================================================ */
6
+ .pui-collapsible {
7
+ border: var(--stroke-sm) solid var(--border-clr-base);
8
+ border-radius: var(--radius-md);
9
+ background: var(--bg-clr-surface);
10
+ }
11
+
12
+ /* ============================================================
13
+ * Summary — the focusable / clickable trigger. Native <summary>
14
+ * carries activation semantics for free; we style it to look
15
+ * like a clickable row with a chevron affordance.
16
+ * ============================================================ */
17
+ .pui-collapsible__trigger {
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: space-between;
21
+ gap: var(--space-s);
22
+ padding: var(--space-2xs) var(--space-s);
23
+ cursor: pointer;
24
+ font: inherit;
25
+ font-weight: var(--fw-semibold);
26
+ color: var(--text-clr-base);
27
+ list-style: none; /* hide default disclosure triangle (Firefox) */
28
+ }
29
+
30
+ /* Chrome / Safari hide the default marker with this pseudo. */
31
+ .pui-collapsible__trigger::-webkit-details-marker {
32
+ display: none;
33
+ }
34
+
35
+ .pui-collapsible__trigger:hover {
36
+ background: var(--bg-clr-surface-2);
37
+ }
38
+
39
+ .pui-collapsible__trigger:focus-visible {
40
+ outline: 3px solid var(--outline-clr-base);
41
+ outline-offset: 2px;
42
+ }
43
+
44
+ .pui-collapsible__label {
45
+ flex: 1 1 auto;
46
+ min-width: 0;
47
+ }
48
+
49
+ /* ============================================================
50
+ * Chevron — rotates to indicate open state via [open] attribute
51
+ * on the parent <details>. Matches Sidebar group toggle and the
52
+ * locked CSS-tokens-only rule.
53
+ * ============================================================ */
54
+ .pui-collapsible__chevron {
55
+ flex: 0 0 auto;
56
+ color: var(--text-clr-muted);
57
+ transition: transform 180ms ease;
58
+ }
59
+
60
+ .pui-collapsible[open]
61
+ > .pui-collapsible__trigger
62
+ > .pui-collapsible__chevron {
63
+ transform: rotate(180deg);
64
+ }
65
+
66
+ /* ============================================================
67
+ * Content — the disclosure panel. Border separates it visually
68
+ * from the summary; padding matches the trigger's inline padding
69
+ * for vertical alignment.
70
+ * ============================================================ */
71
+ .pui-collapsible__content {
72
+ padding: var(--space-s);
73
+ border-block-start: var(--stroke-sm) solid var(--border-clr-base);
74
+ color: var(--text-clr-base);
75
+ }
76
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ import './separator.css';
3
+
4
+ interface Props {
5
+ orientation?: 'horizontal' | 'vertical';
6
+ decorative?: boolean;
7
+ }
8
+
9
+ const { orientation = 'horizontal', decorative = false, ...rest } = Astro.props;
10
+ ---
11
+
12
+ {
13
+ decorative ? (
14
+ <div
15
+ class="pui-separator"
16
+ data-orientation={orientation}
17
+ role="none"
18
+ {...rest}
19
+ />
20
+ ) : orientation === 'vertical' ? (
21
+ <div
22
+ class="pui-separator"
23
+ data-orientation="vertical"
24
+ role="separator"
25
+ aria-orientation="vertical"
26
+ {...rest}
27
+ />
28
+ ) : (
29
+ <hr class="pui-separator" data-orientation="horizontal" {...rest} />
30
+ )
31
+ }
@@ -0,0 +1,33 @@
1
+ <template>
2
+ <div
3
+ v-if="decorative"
4
+ class="pui-separator"
5
+ :data-orientation="orientation"
6
+ role="none"
7
+ />
8
+ <div
9
+ v-else-if="orientation === 'vertical'"
10
+ class="pui-separator"
11
+ data-orientation="vertical"
12
+ role="separator"
13
+ aria-orientation="vertical"
14
+ />
15
+ <hr v-else class="pui-separator" data-orientation="horizontal" />
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ withDefaults(
20
+ defineProps<{
21
+ orientation?: 'horizontal' | 'vertical';
22
+ decorative?: boolean;
23
+ }>(),
24
+ {
25
+ orientation: 'horizontal',
26
+ decorative: false,
27
+ },
28
+ );
29
+ </script>
30
+
31
+ <style>
32
+ @import './separator.css';
33
+ </style>
@@ -0,0 +1,27 @@
1
+ @layer components {
2
+ .pui-separator {
3
+ /* Reset native <hr> defaults so the component fully owns sizing.
4
+ * Browsers paint <hr> with a 1px inset border and default block
5
+ * margins; both are zeroed and replaced with token-driven values. */
6
+ flex-shrink: 0;
7
+ border: 0;
8
+ margin: 0;
9
+ background-color: var(--border-clr-base);
10
+ }
11
+
12
+ .pui-separator[data-orientation='horizontal'] {
13
+ display: block;
14
+ width: 100%;
15
+ height: var(--stroke-sm);
16
+ }
17
+
18
+ .pui-separator[data-orientation='vertical'] {
19
+ display: inline-block;
20
+ width: var(--stroke-sm);
21
+ height: 100%;
22
+ /* Allow the consumer's flex/grid context to size the height; falls
23
+ * back to 1em when the parent does not constrain block-size. */
24
+ min-height: 1em;
25
+ align-self: stretch;
26
+ }
27
+ }
@@ -0,0 +1,127 @@
1
+ <!--
2
+ DataTable — the data-driven wrapper over `Table` (ADR 0003), generic over the row type T.
3
+ Cells render from `row[column.key]` unless overridden by a `#cell:<key>="{ row, value }"` slot.
4
+ -->
5
+ <template>
6
+ <Table
7
+ :sort="sort"
8
+ :loading="loading"
9
+ :columns="columns"
10
+ :selectable="selectable"
11
+ :clickable="clickable"
12
+ :all-selected="allSelected"
13
+ @toggle-all="toggleAll"
14
+ @sort="(key) => emit('sort', key)"
15
+ >
16
+ <tr
17
+ v-for="row in rows"
18
+ :key="String(keyOf(row))"
19
+ class="pui-table__row"
20
+ :data-selected="isSelected(keyOf(row)) || undefined"
21
+ :data-clickable="clickable || undefined"
22
+ @click="onRowClick($event, row)"
23
+ >
24
+ <td v-if="selectable" class="pui-table__cell" data-select>
25
+ <input
26
+ class="pui-table__check"
27
+ type="checkbox"
28
+ :checked="isSelected(keyOf(row))"
29
+ aria-label="Select row"
30
+ @click.stop
31
+ @change="toggle(keyOf(row))"
32
+ />
33
+ </td>
34
+ <td
35
+ v-for="col in columns"
36
+ :key="col.key"
37
+ class="pui-table__cell"
38
+ :data-align="col.align"
39
+ >
40
+ <slot
41
+ :name="`cell:${col.key}`"
42
+ :row="row"
43
+ :value="cellValue(row, col.key)"
44
+ >
45
+ {{ cellValue(row, col.key) }}
46
+ </slot>
47
+ </td>
48
+ </tr>
49
+ <!-- Suppressed while loading: an empty `rows` mid-fetch means "not yet", not "nothing". -->
50
+ <tr v-if="!rows.length && !loading" class="pui-table__row">
51
+ <td class="pui-table__empty">
52
+ <slot name="empty">No results.</slot>
53
+ </td>
54
+ </tr>
55
+ </Table>
56
+ </template>
57
+
58
+ <script setup lang="ts" generic="T extends object">
59
+ import { computed } from 'vue';
60
+ import Table from './Table.vue';
61
+ import type { Column, SortState } from './types.js';
62
+
63
+ const props = withDefaults(
64
+ defineProps<{
65
+ /** Column layout. */
66
+ columns: Column[];
67
+ /** Row data. */
68
+ rows: T[];
69
+ /** Property on each row that uniquely identifies it. */
70
+ rowKey?: string;
71
+ /** Selected row keys — use with `v-model:selected`. */
72
+ selected?: (string | number)[];
73
+ /** Render a selection gutter (checkbox column). */
74
+ selectable?: boolean;
75
+ /** Show a pointer cursor on rows. */
76
+ clickable?: boolean;
77
+ /** Which column is sorted, and how. `rows` is passed through untouched — ordering them is the consumer's job (ADR 0005). */
78
+ sort?: SortState;
79
+ /** Frosts and disables the body while the rows are stale, and suppresses the empty state. */
80
+ loading?: boolean;
81
+ }>(),
82
+ {
83
+ rowKey: 'id',
84
+ selected: () => [],
85
+ selectable: false,
86
+ clickable: false,
87
+ sort: null,
88
+ loading: false,
89
+ },
90
+ );
91
+
92
+ const emit = defineEmits<{
93
+ 'update:selected': [keys: (string | number)[]];
94
+ 'row-click': [row: T];
95
+ /** A sortable header was activated, carrying that column's `key`. */
96
+ sort: [key: string];
97
+ }>();
98
+
99
+ /** Read a field off a row by key; interface row types lack an index signature, hence the cast. */
100
+ function cellValue(row: T, key: string): unknown {
101
+ return (row as Record<string, unknown>)[key];
102
+ }
103
+ function keyOf(row: T): string | number {
104
+ return cellValue(row, props.rowKey) as string | number;
105
+ }
106
+ function isSelected(key: string | number): boolean {
107
+ return props.selected.includes(key);
108
+ }
109
+ function toggle(key: string | number) {
110
+ const next = isSelected(key)
111
+ ? props.selected.filter((k) => k !== key)
112
+ : [...props.selected, key];
113
+ emit('update:selected', next);
114
+ }
115
+ const allSelected = computed(
116
+ () =>
117
+ props.rows.length > 0 && props.rows.every((r) => isSelected(keyOf(r))),
118
+ );
119
+ function toggleAll() {
120
+ emit('update:selected', allSelected.value ? [] : props.rows.map(keyOf));
121
+ }
122
+
123
+ function onRowClick(event: MouseEvent, row: T) {
124
+ if ((event.target as HTMLElement).closest('button, a')) return;
125
+ emit('row-click', row);
126
+ }
127
+ </script>
@@ -0,0 +1,116 @@
1
+ ---
2
+ // Table (Astro) — the static primitive; the consumer authors the body in the default slot with
3
+ // the BEM classes. Sortable headers are <a>s, not events: sorting a static table is a navigation.
4
+ import './table.css';
5
+ import Icon from '../../display/icon/Icon.astro';
6
+ import Spinner from '../../feedback/spinner/Spinner.astro';
7
+ import type { IconName } from '../../../icons/index.js';
8
+ import {
9
+ formatSort,
10
+ toggleSort,
11
+ type SortDirection,
12
+ type SortState,
13
+ } from '../../../utils/sort/index.js';
14
+ import type { Column } from './types.js';
15
+
16
+ interface Props {
17
+ columns: Column[];
18
+ /** Which column is sorted, and how. Rendered here; the page orders the rows itself (ADR 0005). */
19
+ sort?: SortState;
20
+ /** Query parameter the header links read and write. */
21
+ sortParam?: string;
22
+ /** Frosts and disables the body while the rows are stale, leaving the header usable. */
23
+ loading?: boolean;
24
+ }
25
+
26
+ const {
27
+ columns,
28
+ sort = null,
29
+ sortParam = 'sort',
30
+ loading = false,
31
+ ...rest
32
+ } = Astro.props;
33
+ const cols = columns.map((c) => c.width ?? 'auto').join(' ');
34
+
35
+ /** Header glyph per sort state — the shape carries the direction. */
36
+ const SORT_ICONS: Record<SortDirection | 'none', IconName> = {
37
+ asc: 'chevron-up',
38
+ desc: 'chevron-down',
39
+ none: 'chevron-up-down',
40
+ };
41
+
42
+ function sortState(key: string): SortDirection | 'none' {
43
+ return sort?.key === key ? sort.direction : 'none';
44
+ }
45
+
46
+ /** `aria-sort` token for the header cell; only the sorted column carries one. */
47
+ function ariaSort(key: string): 'ascending' | 'descending' | undefined {
48
+ if (sort?.key !== key) return undefined;
49
+ return sort.direction === 'asc' ? 'ascending' : 'descending';
50
+ }
51
+
52
+ /** Where this column's header links: the asc → desc → unsorted cycle, other query params preserved. */
53
+ function sortHref(key: string): string {
54
+ const url = new URL(Astro.url);
55
+ const value = formatSort(toggleSort(sort, key));
56
+ if (value) url.searchParams.set(sortParam, value);
57
+ else url.searchParams.delete(sortParam);
58
+ return url.pathname + url.search;
59
+ }
60
+ ---
61
+
62
+ <div class="pui-table-wrap" {...rest}>
63
+ <table
64
+ class="pui-table"
65
+ style={`--pui-table-cols: ${cols}`}
66
+ aria-busy={loading ? 'true' : undefined}
67
+ >
68
+ <thead class="pui-table__head">
69
+ <tr class="pui-table__row" data-head>
70
+ {
71
+ columns.map((col) => (
72
+ <th
73
+ scope="col"
74
+ class="pui-table__cell"
75
+ data-head
76
+ data-align={col.align}
77
+ aria-sort={col.sortable ? ariaSort(col.key) : undefined}
78
+ >
79
+ {col.sortable ? (
80
+ <a class="pui-table__sort" href={sortHref(col.key)}>
81
+ {col.label}
82
+ <span
83
+ class="pui-table__sort-arrow"
84
+ data-sort={sortState(col.key)}
85
+ >
86
+ <Icon name={SORT_ICONS[sortState(col.key)]} size="sm" />
87
+ </span>
88
+ </a>
89
+ ) : (
90
+ col.label
91
+ )}
92
+ </th>
93
+ ))
94
+ }
95
+ </tr>
96
+ </thead>
97
+ {
98
+ /* `inert`, not CSS: `pointer-events` only stops the mouse and leaves the stale rows tabbable. */
99
+ }
100
+ <tbody class="pui-table__body" inert={loading ? true : undefined}>
101
+ <slot />
102
+ {
103
+ /* Must be a real <tr>/<td> inside the body, or the HTML parser hoists it out — see AUDIT.md. */
104
+ }
105
+ {
106
+ loading && (
107
+ <tr class="pui-table__loading" aria-hidden="true">
108
+ <td>
109
+ <Spinner size="xl" />
110
+ </td>
111
+ </tr>
112
+ )
113
+ }
114
+ </tbody>
115
+ </table>
116
+ </div>
@@ -0,0 +1,146 @@
1
+ <!--
2
+ Table — the primitive shell (ADR 0003): header from `columns`, default slot for the body,
3
+ which takes `TableRow`s or plain `<tr>`s. Column layout is provided to child `TableRow`s.
4
+ -->
5
+ <template>
6
+ <div class="pui-table-wrap">
7
+ <table
8
+ class="pui-table"
9
+ :style="{ '--pui-table-cols': cols }"
10
+ :aria-busy="loading || undefined"
11
+ >
12
+ <thead class="pui-table__head">
13
+ <tr class="pui-table__row" data-head>
14
+ <th
15
+ v-if="selectable"
16
+ class="pui-table__cell"
17
+ data-head
18
+ data-select
19
+ >
20
+ <input
21
+ class="pui-table__check"
22
+ type="checkbox"
23
+ :checked="allSelected"
24
+ aria-label="Select all rows"
25
+ @change="emit('toggle-all')"
26
+ />
27
+ </th>
28
+ <th
29
+ v-for="col in columns"
30
+ :key="col.key"
31
+ class="pui-table__cell"
32
+ data-head
33
+ :data-align="col.align"
34
+ :aria-sort="
35
+ col.sortable ? ariaSort(col.key) : undefined
36
+ "
37
+ scope="col"
38
+ >
39
+ <button
40
+ v-if="col.sortable"
41
+ type="button"
42
+ class="pui-table__sort"
43
+ @click="emit('sort', col.key)"
44
+ >
45
+ {{ col.label }}
46
+ <span
47
+ class="pui-table__sort-arrow"
48
+ :data-sort="state(col.key)"
49
+ >
50
+ <Icon
51
+ :name="SORT_ICONS[state(col.key)]"
52
+ size="sm"
53
+ />
54
+ </span>
55
+ </button>
56
+ <template v-else>{{ col.label }}</template>
57
+ </th>
58
+ </tr>
59
+ </thead>
60
+ <!-- `inert`, not CSS: `pointer-events` only stops the mouse and leaves the stale rows tabbable. -->
61
+ <tbody class="pui-table__body" :inert="loading || undefined">
62
+ <slot />
63
+ <!-- Inside the body so it covers the rows and not the header; must be a real
64
+ <tr>/<td> or the HTML parser hoists it out of the table — see AUDIT.md. -->
65
+ <tr
66
+ v-if="loading"
67
+ class="pui-table__loading"
68
+ aria-hidden="true"
69
+ >
70
+ <td><Spinner size="xl" /></td>
71
+ </tr>
72
+ </tbody>
73
+ </table>
74
+ </div>
75
+ </template>
76
+
77
+ <script setup lang="ts">
78
+ import { computed, provide } from 'vue';
79
+ import Icon from '../../display/icon/Icon.vue';
80
+ import Spinner from '../../feedback/spinner/Spinner.vue';
81
+ import type { IconName } from '../../../icons/index.js';
82
+ import type { SortDirection, SortState } from '../../../utils/sort/index.js';
83
+ import { TABLE_COLUMNS, type Column } from './types.js';
84
+ import { gridColumns } from './useTable.js';
85
+
86
+ /** Header glyph per sort state — the shape carries the direction. */
87
+ const SORT_ICONS: Record<SortDirection | 'none', IconName> = {
88
+ asc: 'chevron-up',
89
+ desc: 'chevron-down',
90
+ none: 'chevron-up-down',
91
+ };
92
+
93
+ const props = withDefaults(
94
+ defineProps<{
95
+ /** Column layout. */
96
+ columns: Column[];
97
+ /** Render a leading selection gutter (checkbox column). */
98
+ selectable?: boolean;
99
+ /** Show a pointer cursor on rows (for clickable/selectable rows). */
100
+ clickable?: boolean;
101
+ /** Checked state of the header select-all checkbox. */
102
+ allSelected?: boolean;
103
+ /** Which column is sorted, and how. Rendered, never changed here; `null` is unsorted (ADR 0005). */
104
+ sort?: SortState;
105
+ /** Frosts and disables the body while the rows are stale, leaving the header usable. */
106
+ loading?: boolean;
107
+ }>(),
108
+ {
109
+ selectable: false,
110
+ clickable: false,
111
+ allSelected: false,
112
+ sort: null,
113
+ loading: false,
114
+ },
115
+ );
116
+
117
+ const emit = defineEmits<{
118
+ 'toggle-all': [];
119
+ /** A sortable header was activated, carrying that column's `key` — the intent, not the next state (ADR 0005). */
120
+ sort: [key: string];
121
+ }>();
122
+
123
+ // TableRow reads layout from here instead of taking `columns` again.
124
+ provide(TABLE_COLUMNS, {
125
+ columns: computed(() => props.columns),
126
+ selectable: computed(() => props.selectable),
127
+ clickable: computed(() => props.clickable),
128
+ });
129
+
130
+ const cols = computed(() => gridColumns(props.columns, props.selectable));
131
+
132
+ /** Sort state of one column, driving the header glyph. */
133
+ function state(key: string): SortDirection | 'none' {
134
+ return props.sort?.key === key ? props.sort.direction : 'none';
135
+ }
136
+
137
+ /** `aria-sort` token for the header cell; only the sorted column carries one. */
138
+ function ariaSort(key: string): 'ascending' | 'descending' | undefined {
139
+ if (props.sort?.key !== key) return undefined;
140
+ return props.sort.direction === 'asc' ? 'ascending' : 'descending';
141
+ }
142
+ </script>
143
+
144
+ <style>
145
+ @import './table.css';
146
+ </style>
@@ -0,0 +1,59 @@
1
+ <!--
2
+ TableRow — one row of the composed primitive. One named slot per column key (`#<key>`).
3
+ Reads column layout from the parent `Table` via inject. Emits `row-click`, auto-suppressed
4
+ when the click lands on a button or link inside the row, so action controls coexist with a
5
+ clickable/selectable row. Selection is externalized: the caller owns the set and passes `selected`.
6
+ -->
7
+ <template>
8
+ <tr
9
+ class="pui-table__row"
10
+ :data-selected="selected || undefined"
11
+ :data-clickable="ctx.clickable.value || undefined"
12
+ @click="onClick"
13
+ >
14
+ <td v-if="ctx.selectable.value" class="pui-table__cell" data-select>
15
+ <input
16
+ class="pui-table__check"
17
+ type="checkbox"
18
+ :checked="selected"
19
+ tabindex="-1"
20
+ aria-hidden="true"
21
+ @click.prevent
22
+ />
23
+ </td>
24
+ <td
25
+ v-for="col in ctx.columns.value"
26
+ :key="col.key"
27
+ class="pui-table__cell"
28
+ :data-align="col.align"
29
+ >
30
+ <slot :name="col.key" />
31
+ </td>
32
+ </tr>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { inject } from 'vue';
37
+ import { TABLE_COLUMNS } from './types.js';
38
+
39
+ withDefaults(
40
+ defineProps<{
41
+ /** Selected styling. Selection state is owned by the caller. */
42
+ selected?: boolean;
43
+ }>(),
44
+ { selected: false },
45
+ );
46
+
47
+ const emit = defineEmits<{ 'row-click': [event: MouseEvent] }>();
48
+
49
+ const ctx = inject(TABLE_COLUMNS);
50
+ if (!ctx) {
51
+ throw new Error('[pui] TableRow must be used inside a Table.');
52
+ }
53
+
54
+ function onClick(event: MouseEvent) {
55
+ // Don't hijack clicks on interactive controls inside the row.
56
+ if ((event.target as HTMLElement).closest('button, a')) return;
57
+ emit('row-click', event);
58
+ }
59
+ </script>