@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,155 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
4
+ <div class="pui-number-field">
5
+ <input
6
+ v-bind="$attrs"
7
+ :id="inputId"
8
+ ref="inputRef"
9
+ class="pui-number-field__input pui-input"
10
+ type="number"
11
+ :name="name"
12
+ :min="min"
13
+ :max="max"
14
+ :step="step"
15
+ :placeholder="placeholder"
16
+ :required="required"
17
+ :disabled="disabled"
18
+ :value="modelValue"
19
+ :aria-invalid="hasErrors ? 'true' : undefined"
20
+ :aria-describedby="describedBy"
21
+ @input="onInput"
22
+ @blur="emit('blur')"
23
+ />
24
+ <div class="pui-number-field__steppers">
25
+ <button
26
+ type="button"
27
+ class="pui-number-field__step"
28
+ data-direction="increment"
29
+ aria-label="Increase"
30
+ :disabled="disabled"
31
+ @click="handleStep('increment')"
32
+ >
33
+ <span aria-hidden="true">+</span>
34
+ </button>
35
+ <button
36
+ type="button"
37
+ class="pui-number-field__step"
38
+ data-direction="decrement"
39
+ aria-label="Decrease"
40
+ :disabled="disabled"
41
+ @click="handleStep('decrement')"
42
+ >
43
+ <span aria-hidden="true">&#x2212;</span>
44
+ </button>
45
+ </div>
46
+ </div>
47
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
48
+ <ul
49
+ v-if="errors.length"
50
+ :id="errorsId"
51
+ class="pui-field__hint"
52
+ role="alert"
53
+ >
54
+ <li v-for="error in errors" :key="error">{{ error }}</li>
55
+ </ul>
56
+ </div>
57
+ </template>
58
+
59
+ <script setup lang="ts">
60
+ import { computed, ref } from 'vue';
61
+ import { generateId } from '../../../utils/a11y/id.js';
62
+
63
+ defineOptions({ inheritAttrs: false });
64
+
65
+ const props = withDefaults(
66
+ defineProps<{
67
+ id?: string;
68
+ label: string;
69
+ /**
70
+ * Current numeric value. `undefined` represents an empty input;
71
+ * `NaN` is also tolerated (the native input renders empty when
72
+ * its value is not a finite number).
73
+ */
74
+ modelValue?: number;
75
+ name?: string;
76
+ min?: number;
77
+ max?: number;
78
+ step?: number | string;
79
+ placeholder?: string;
80
+ required?: boolean;
81
+ disabled?: boolean;
82
+ hint?: string;
83
+ errors?: string[];
84
+ status?: 'error' | 'success';
85
+ }>(),
86
+ {
87
+ id: undefined,
88
+ modelValue: undefined,
89
+ name: undefined,
90
+ min: undefined,
91
+ max: undefined,
92
+ step: undefined,
93
+ placeholder: undefined,
94
+ required: false,
95
+ disabled: false,
96
+ hint: undefined,
97
+ errors: () => [],
98
+ status: undefined,
99
+ },
100
+ );
101
+
102
+ const emit = defineEmits<{
103
+ 'update:modelValue': [value: number];
104
+ blur: [];
105
+ }>();
106
+
107
+ const inputRef = ref<HTMLInputElement | null>(null);
108
+
109
+ const inputId = props.id ?? generateId('input');
110
+ const hintId = `${inputId}-hint`;
111
+ const errorsId = `${inputId}-errors`;
112
+
113
+ const hasErrors = computed(() => props.errors.length > 0);
114
+
115
+ const computedStatus = computed(() => {
116
+ if (props.status) return props.status;
117
+ if (hasErrors.value) return 'error';
118
+ return undefined;
119
+ });
120
+
121
+ const describedBy = computed(() => {
122
+ const ids: string[] = [];
123
+ if (props.hint) ids.push(hintId);
124
+ if (hasErrors.value) ids.push(errorsId);
125
+ return ids.length > 0 ? ids.join(' ') : undefined;
126
+ });
127
+
128
+ function onInput(event: Event): void {
129
+ const target = event.target as HTMLInputElement;
130
+ // valueAsNumber is NaN when the input is empty or otherwise not a
131
+ // finite number. We pass it through unchanged so consumers can
132
+ // distinguish "cleared field" from "explicit 0".
133
+ emit('update:modelValue', target.valueAsNumber);
134
+ }
135
+
136
+ function handleStep(direction: 'decrement' | 'increment'): void {
137
+ if (props.disabled) return;
138
+ const input = inputRef.value;
139
+ if (!input) return;
140
+ if (direction === 'increment') {
141
+ input.stepUp();
142
+ } else {
143
+ input.stepDown();
144
+ }
145
+ // Dispatch native input/change events so consumer-attached DOM
146
+ // listeners observe the same behavior as the Astro controller path.
147
+ input.dispatchEvent(new Event('input', { bubbles: true }));
148
+ input.dispatchEvent(new Event('change', { bubbles: true }));
149
+ emit('update:modelValue', input.valueAsNumber);
150
+ }
151
+ </script>
152
+
153
+ <style>
154
+ @import './number-field.css';
155
+ </style>
@@ -0,0 +1,115 @@
1
+ @layer components {
2
+ /* ===== pui-number-field (input + flanking step buttons) =====
3
+ *
4
+ * NumberField extends the locked Form-primitive scaffold (the outer
5
+ * `.pui-field` wrapper from `text-input.css`) with an inline-flex
6
+ * row that hosts the native `<input type="number">` on the left and
7
+ * two stacked step buttons on the right. The native browser spinner
8
+ * is suppressed via `appearance: textfield` (Webkit / Firefox)
9
+ * because we provide our own larger, focus-visible step buttons —
10
+ * the locked layout choice per the NumberField audit.
11
+ *
12
+ * Layout rationale: input + stacked step buttons (increment-top /
13
+ * decrement-bottom) matches the Reka UI reference and the visual
14
+ * intuition of "up to grow, down to shrink". The buttons share the
15
+ * input's border so they read as one integrated control while
16
+ * keeping each segment independently focusable for keyboard users.
17
+ */
18
+
19
+ .pui-number-field {
20
+ display: inline-flex;
21
+ align-items: stretch;
22
+ width: 100%;
23
+ }
24
+
25
+ .pui-number-field__input {
26
+ /* Inherits sizing, padding, focus-ring, disabled, and status
27
+ * recolour from the shared .pui-input rule (text-input.css). The
28
+ * overrides here only adjust the right-side border radii so the
29
+ * input segment butts cleanly against the stepper column. */
30
+ flex: 1;
31
+ border-top-right-radius: 0;
32
+ border-bottom-right-radius: 0;
33
+ /* Suppress the native spinner — we provide our own buttons. */
34
+ appearance: textfield;
35
+ -moz-appearance: textfield;
36
+ }
37
+
38
+ .pui-number-field__input::-webkit-inner-spin-button,
39
+ .pui-number-field__input::-webkit-outer-spin-button {
40
+ appearance: none;
41
+ -webkit-appearance: none;
42
+ margin: 0;
43
+ }
44
+
45
+ /* Stacked column for the +/- buttons. Sits flush against the
46
+ * input's right edge; each button takes half the column height. */
47
+ .pui-number-field__steppers {
48
+ display: flex;
49
+ flex-direction: column;
50
+ }
51
+
52
+ .pui-number-field__step {
53
+ appearance: none;
54
+ background: var(--bg-clr-surface);
55
+ color: var(--text-clr-base);
56
+ border: var(--stroke-sm) solid var(--border-clr-base);
57
+ /* Avoid double border between the input and the stepper column
58
+ * (the input already paints the shared edge). */
59
+ border-left: 0;
60
+ cursor: pointer;
61
+ flex: 1;
62
+ font: inherit;
63
+ font-weight: var(--fw-bold);
64
+ line-height: 1;
65
+ padding: 0 var(--space-2xs);
66
+ display: grid;
67
+ place-items: center;
68
+ /* Ensure the stacked buttons fit the input height even when the
69
+ * font happens to be larger than the input's line-box. */
70
+ min-width: 1.75rem;
71
+ }
72
+
73
+ .pui-number-field__step[data-direction='increment'] {
74
+ border-top-right-radius: var(--radius-sm);
75
+ /* Avoid double border between the two stacked buttons. */
76
+ border-bottom: 0;
77
+ }
78
+
79
+ .pui-number-field__step[data-direction='decrement'] {
80
+ border-bottom-right-radius: var(--radius-sm);
81
+ }
82
+
83
+ .pui-number-field__step:hover:not(:disabled) {
84
+ background: var(--bg-clr-surface-2);
85
+ }
86
+
87
+ .pui-number-field__step:focus-visible {
88
+ outline: 3px solid var(--outline-clr-base);
89
+ outline-offset: 2px;
90
+ /* Lift the focused button above its neighbours so the outline
91
+ * isn't clipped by the adjacent button's edge. */
92
+ position: relative;
93
+ z-index: 1;
94
+ }
95
+
96
+ .pui-number-field__step:disabled {
97
+ background: var(--bg-clr-surface-2);
98
+ color: var(--text-clr-muted);
99
+ cursor: not-allowed;
100
+ }
101
+
102
+ /* Status states inherited from .pui-field — recolour BOTH the input
103
+ * and the step buttons so the whole control reads as errored /
104
+ * succeeded. Mirrors the .pui-input / .pui-textarea status recolour in
105
+ * text-input.css. */
106
+ .pui-field[data-status='error'] .pui-number-field__input,
107
+ .pui-field[data-status='error'] .pui-number-field__step {
108
+ border-color: var(--border-clr-danger);
109
+ }
110
+
111
+ .pui-field[data-status='success'] .pui-number-field__input,
112
+ .pui-field[data-status='success'] .pui-number-field__step {
113
+ border-color: var(--border-clr-success);
114
+ }
115
+ }
@@ -0,0 +1,105 @@
1
+ ---
2
+ import './radio-group.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+ import type { RadioOption } from './types.js';
5
+
6
+ interface Props {
7
+ id?: string;
8
+ label: string;
9
+ name: string;
10
+ options: RadioOption[];
11
+ value?: string;
12
+ disabled?: boolean;
13
+ orientation?: 'vertical' | 'horizontal';
14
+ hint?: string;
15
+ errors?: string[];
16
+ status?: 'error' | 'success';
17
+ }
18
+
19
+ const {
20
+ id,
21
+ label,
22
+ name,
23
+ options,
24
+ value,
25
+ disabled = false,
26
+ orientation = 'vertical',
27
+ hint,
28
+ errors = [],
29
+ status,
30
+ ...rest
31
+ } = Astro.props;
32
+
33
+ const fieldId = id ?? generateId('radiogroup');
34
+ const hintId = `${fieldId}-hint`;
35
+ const errorsId = `${fieldId}-errors`;
36
+
37
+ const hasErrors = errors.length > 0;
38
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
39
+
40
+ const describedByIds: string[] = [];
41
+ if (hint) describedByIds.push(hintId);
42
+ if (hasErrors) describedByIds.push(errorsId);
43
+ const describedBy =
44
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
45
+ ---
46
+
47
+ <fieldset
48
+ id={fieldId}
49
+ class="pui-field"
50
+ data-status={computedStatus}
51
+ role="radiogroup"
52
+ aria-describedby={describedBy}
53
+ aria-invalid={hasErrors ? 'true' : undefined}
54
+ {...rest}
55
+ >
56
+ {
57
+ /* aria-invalid lives on the <fieldset> (group-level signal) AND, when the
58
+ Astro mountForm controller is wired, on the first radio. The redundancy
59
+ is intentional — see CONVENTIONS.md "RadioGroup audit → ARIA
60
+ expectations" for the rationale. Do NOT remove either binding without
61
+ coordinating with the form controller. */
62
+ }
63
+ <legend class="pui-field__label">{label}</legend>
64
+ {
65
+ hint && (
66
+ <p id={hintId} class="pui-field__hint">
67
+ {hint}
68
+ </p>
69
+ )
70
+ }
71
+ <div class="pui-radio-group" data-orientation={orientation}>
72
+ {
73
+ options.map((option) => {
74
+ const isChecked = value === option.value;
75
+ const isDisabled = option.disabled || disabled;
76
+ return (
77
+ <label
78
+ class="pui-radio-group__item"
79
+ data-disabled={isDisabled ? 'true' : undefined}
80
+ >
81
+ <input
82
+ class="pui-radio"
83
+ type="radio"
84
+ name={name}
85
+ value={option.value}
86
+ checked={isChecked}
87
+ disabled={isDisabled}
88
+ aria-checked={isChecked ? 'true' : 'false'}
89
+ />
90
+ <span class="pui-radio-group__label">{option.label}</span>
91
+ </label>
92
+ );
93
+ })
94
+ }
95
+ </div>
96
+ {
97
+ hasErrors && (
98
+ <ul id={errorsId} class="pui-field__hint" role="alert">
99
+ {errors.map((error) => (
100
+ <li>{error}</li>
101
+ ))}
102
+ </ul>
103
+ )
104
+ }
105
+ </fieldset>
@@ -0,0 +1,110 @@
1
+ <template>
2
+ <fieldset
3
+ v-bind="$attrs"
4
+ :id="fieldId"
5
+ class="pui-field"
6
+ :data-status="computedStatus"
7
+ role="radiogroup"
8
+ :aria-describedby="describedBy"
9
+ :aria-invalid="hasErrors ? 'true' : undefined"
10
+ >
11
+ <!-- aria-invalid lives on the <fieldset> (group-level signal) AND, when the
12
+ Astro mountForm controller is wired, on the first radio. The redundancy
13
+ is intentional — see ./AUDIT.md "ARIA
14
+ expectations" for the rationale. Do NOT remove either binding without
15
+ coordinating with the form controller. -->
16
+ <legend class="pui-field__label">{{ label }}</legend>
17
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
18
+ <div class="pui-radio-group" :data-orientation="orientation">
19
+ <label
20
+ v-for="option in options"
21
+ :key="option.value"
22
+ class="pui-radio-group__item"
23
+ :data-disabled="
24
+ option.disabled || disabled ? 'true' : undefined
25
+ "
26
+ >
27
+ <input
28
+ class="pui-radio"
29
+ type="radio"
30
+ :name="name"
31
+ :value="option.value"
32
+ :checked="modelValue === option.value"
33
+ :disabled="option.disabled || disabled"
34
+ :aria-checked="
35
+ modelValue === option.value ? 'true' : 'false'
36
+ "
37
+ @change="emit('update:modelValue', option.value)"
38
+ />
39
+ <span class="pui-radio-group__label">{{ option.label }}</span>
40
+ </label>
41
+ </div>
42
+ <ul
43
+ v-if="errors.length"
44
+ :id="errorsId"
45
+ class="pui-field__hint"
46
+ role="alert"
47
+ >
48
+ <li v-for="error in errors" :key="error">{{ error }}</li>
49
+ </ul>
50
+ </fieldset>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed } from 'vue';
55
+ import { generateId } from '../../../utils/a11y/id.js';
56
+ import type { RadioOption } from './types.js';
57
+
58
+ defineOptions({ inheritAttrs: false });
59
+
60
+ const props = withDefaults(
61
+ defineProps<{
62
+ id?: string;
63
+ label: string;
64
+ name: string;
65
+ options: RadioOption[];
66
+ modelValue?: string;
67
+ disabled?: boolean;
68
+ orientation?: 'vertical' | 'horizontal';
69
+ hint?: string;
70
+ errors?: string[];
71
+ status?: 'error' | 'success';
72
+ }>(),
73
+ {
74
+ id: undefined,
75
+ modelValue: undefined,
76
+ disabled: false,
77
+ orientation: 'vertical',
78
+ hint: undefined,
79
+ errors: () => [],
80
+ status: undefined,
81
+ },
82
+ );
83
+
84
+ const emit = defineEmits<{
85
+ 'update:modelValue': [value: string];
86
+ }>();
87
+
88
+ const fieldId = props.id ?? generateId('radiogroup');
89
+ const hintId = `${fieldId}-hint`;
90
+ const errorsId = `${fieldId}-errors`;
91
+
92
+ const hasErrors = computed(() => props.errors.length > 0);
93
+
94
+ const computedStatus = computed(() => {
95
+ if (props.status) return props.status;
96
+ if (hasErrors.value) return 'error';
97
+ return undefined;
98
+ });
99
+
100
+ const describedBy = computed(() => {
101
+ const ids: string[] = [];
102
+ if (props.hint) ids.push(hintId);
103
+ if (hasErrors.value) ids.push(errorsId);
104
+ return ids.length > 0 ? ids.join(' ') : undefined;
105
+ });
106
+ </script>
107
+
108
+ <style>
109
+ @import './radio-group.css';
110
+ </style>
@@ -0,0 +1,114 @@
1
+ @layer components {
2
+ /* ===== pui-radio-group (group container with orientation) =====
3
+ *
4
+ * Hosted inside a `<fieldset class="pui-field">` form-primitive scaffold —
5
+ * legend (label) above, hint after legend, items below, errors below items.
6
+ * The group container itself only owns layout of the item list; the field
7
+ * chrome (label / hint / errors / data-status recolour) lives on the
8
+ * surrounding pui-field per the locked form-primitive wrapper archetype.
9
+ */
10
+
11
+ .pui-radio-group {
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: var(--space-xs);
15
+ }
16
+
17
+ .pui-radio-group[data-orientation='horizontal'] {
18
+ flex-direction: row;
19
+ flex-wrap: wrap;
20
+ column-gap: var(--space-s);
21
+ }
22
+
23
+ /* Each option is a label-wrapping-input pattern: clicking anywhere on the
24
+ * label area selects the radio. Different from Switch / Checkbox /
25
+ * TextInput which use <label for> association — radio item labels are
26
+ * short string content from the option's `label`, label-wrapping is more
27
+ * compact and well-established for radio lists. */
28
+ .pui-radio-group__item {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: var(--space-2xs);
32
+ cursor: pointer;
33
+ font-size: inherit;
34
+ /* Override base/forms.css `label { font-weight: var(--fw-semibold) }`:
35
+ * radio-item labels are body text, not field labels, and inherit the
36
+ * fieldset's <legend> as the actual field label. */
37
+ font-weight: var(--fw-regular);
38
+ /* Override base/forms.css `label { margin-bottom: var(--space-xs) }`:
39
+ * the surrounding `.pui-radio-group` flex `gap` already governs item
40
+ * spacing; an inherited margin-bottom would compound and split the gap. */
41
+ margin-bottom: 0;
42
+ }
43
+
44
+ .pui-radio-group__item[data-disabled='true'] {
45
+ color: var(--text-clr-muted);
46
+ cursor: not-allowed;
47
+ }
48
+
49
+ .pui-radio-group__label {
50
+ /* `line-height: 1` clipped descenders on long labels that wrapped; 1.2
51
+ * gives breathing room without over-spacing the inline-flex item. */
52
+ line-height: 1.2;
53
+ }
54
+
55
+ /* ===== pui-radio (individual native input) =====
56
+ *
57
+ * Round, custom-styled native <input type="radio"> with a centered inner
58
+ * dot when checked. Native radios with a shared `name` give us arrow-key
59
+ * navigation, only-one-selected, and Tab-focuses-selected for free; no
60
+ * controller required (per the WAI-ARIA APG radiogroup notes — Radix
61
+ * needs roving-tabindex because they use role="radio" on <div> elements;
62
+ * we use the native input).
63
+ */
64
+
65
+ .pui-radio {
66
+ appearance: none;
67
+ flex: none;
68
+ margin: 0;
69
+ width: 1.15em;
70
+ height: 1.15em;
71
+ border: var(--stroke-md) solid var(--border-clr-strong);
72
+ border-radius: 50%;
73
+ background: var(--bg-clr-surface);
74
+ display: grid;
75
+ place-items: center;
76
+ cursor: pointer;
77
+ transition:
78
+ background 0.12s ease,
79
+ border-color 0.12s ease;
80
+ }
81
+
82
+ .pui-radio:checked {
83
+ background: var(--bg-clr-brand);
84
+ border-color: var(--border-clr-brand);
85
+ }
86
+
87
+ .pui-radio:checked::after {
88
+ content: '';
89
+ width: 0.45em;
90
+ height: 0.45em;
91
+ border-radius: 50%;
92
+ background: var(--bg-clr-brand-ink);
93
+ }
94
+
95
+ .pui-radio:focus-visible {
96
+ outline: 3px solid var(--outline-clr-base);
97
+ outline-offset: 2px;
98
+ }
99
+
100
+ .pui-radio:disabled {
101
+ opacity: 0.5;
102
+ cursor: not-allowed;
103
+ }
104
+
105
+ /* Status-driven recolour from the parent pui-field wrapper. */
106
+ .pui-field[data-status='error'] .pui-radio {
107
+ border-color: var(--border-clr-danger);
108
+ }
109
+
110
+ .pui-field[data-status='success'] .pui-radio:checked {
111
+ background: var(--bg-clr-success, var(--bg-clr-brand));
112
+ border-color: var(--bg-clr-success, var(--border-clr-brand));
113
+ }
114
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A single radio option entry in a {@link RadioGroup}'s `:options` prop.
3
+ *
4
+ * The flat single-component RadioGroup (per design D2 — flat API) takes a
5
+ * typed array of these instead of compound `<RadioGroupItem>` subcomponents.
6
+ */
7
+ export type RadioOption = {
8
+ /** Form value emitted when this option is selected. */
9
+ value: string;
10
+ /** Display label rendered next to the radio. */
11
+ label: string;
12
+ /** Disable just this option. Group-level disable is out of scope for v1. */
13
+ disabled?: boolean;
14
+ };
@@ -0,0 +1,36 @@
1
+ <template>
2
+ <div class="pui-segmented" role="group" :aria-label="label">
3
+ <button
4
+ v-for="opt in options"
5
+ :key="opt.value"
6
+ class="pui-segmented__item"
7
+ type="button"
8
+ :aria-pressed="modelValue === opt.value ? 'true' : 'false'"
9
+ @click="emit('update:modelValue', opt.value)"
10
+ >
11
+ {{ opt.label }}
12
+ </button>
13
+ </div>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ withDefaults(
18
+ defineProps<{
19
+ modelValue?: string;
20
+ options: { value: string; label: string }[];
21
+ label?: string;
22
+ }>(),
23
+ {
24
+ modelValue: undefined,
25
+ label: undefined,
26
+ },
27
+ );
28
+
29
+ const emit = defineEmits<{
30
+ 'update:modelValue': [value: string];
31
+ }>();
32
+ </script>
33
+
34
+ <style>
35
+ @import './select.css';
36
+ </style>