@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,321 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="thumbId(0)">{{ label }}</label>
4
+ <div class="pui-slider-shell">
5
+ <div
6
+ ref="trackRef"
7
+ class="pui-slider"
8
+ :data-disabled="disabled ? 'true' : undefined"
9
+ :data-range="isRange ? 'true' : undefined"
10
+ :style="trackFillStyle"
11
+ >
12
+ <div class="pui-slider__rail" aria-hidden="true"></div>
13
+ <div class="pui-slider__fill" aria-hidden="true"></div>
14
+ <button
15
+ v-for="(value, idx) in valuesArray"
16
+ :id="thumbId(idx)"
17
+ :key="idx"
18
+ ref="thumbRefs"
19
+ type="button"
20
+ class="pui-slider__thumb"
21
+ role="slider"
22
+ :aria-valuemin="min"
23
+ :aria-valuemax="max"
24
+ :aria-valuenow="value"
25
+ :aria-valuetext="ariaValueText(idx, value)"
26
+ :aria-label="thumbAriaLabel(idx)"
27
+ :aria-disabled="disabled ? 'true' : undefined"
28
+ :aria-describedby="idx === 0 ? describedBy : undefined"
29
+ :style="{ insetInlineStart: thumbPercent(value) + '%' }"
30
+ :tabindex="disabled ? -1 : 0"
31
+ @keydown="(e) => onKeydown(e, idx)"
32
+ @pointerdown="(e) => onThumbPointerDown(e, idx)"
33
+ ></button>
34
+ </div>
35
+ <input
36
+ v-if="name && !isRange"
37
+ type="hidden"
38
+ :name="name"
39
+ :value="modelValue as number"
40
+ />
41
+ <input
42
+ v-if="name && isRange"
43
+ type="hidden"
44
+ :name="`${name}_lower`"
45
+ :value="(modelValue as [number, number])[0]"
46
+ />
47
+ <input
48
+ v-if="name && isRange"
49
+ type="hidden"
50
+ :name="`${name}_upper`"
51
+ :value="(modelValue as [number, number])[1]"
52
+ />
53
+ </div>
54
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
55
+ <ul
56
+ v-if="errors.length"
57
+ :id="errorsId"
58
+ class="pui-field__hint"
59
+ role="alert"
60
+ >
61
+ <li v-for="error in errors" :key="error">{{ error }}</li>
62
+ </ul>
63
+ </div>
64
+ </template>
65
+
66
+ <script setup lang="ts">
67
+ import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
68
+ import { generateId } from '../../../utils/a11y/id.js';
69
+
70
+ const props = withDefaults(
71
+ defineProps<{
72
+ id?: string;
73
+ label: string;
74
+ /**
75
+ * Current value. Pass a `number` for single-thumb mode, a
76
+ * `[number, number]` tuple for range (two-thumb) mode. The shape
77
+ * locks the component into single or range for its lifetime.
78
+ */
79
+ modelValue: number | [number, number];
80
+ name?: string;
81
+ min?: number;
82
+ max?: number;
83
+ step?: number;
84
+ disabled?: boolean;
85
+ hint?: string;
86
+ errors?: string[];
87
+ status?: 'error' | 'success';
88
+ }>(),
89
+ {
90
+ id: undefined,
91
+ name: undefined,
92
+ min: 0,
93
+ max: 100,
94
+ step: 1,
95
+ disabled: false,
96
+ hint: undefined,
97
+ errors: () => [],
98
+ status: undefined,
99
+ },
100
+ );
101
+
102
+ const emit = defineEmits<{
103
+ 'update:modelValue': [value: number | [number, number]];
104
+ }>();
105
+
106
+ const trackRef = ref<HTMLDivElement | null>(null);
107
+ const thumbRefs = ref<HTMLButtonElement[]>([]);
108
+
109
+ const baseId = props.id ?? generateId('input');
110
+ const hintId = `${baseId}-hint`;
111
+ const errorsId = `${baseId}-errors`;
112
+
113
+ function thumbId(idx: number): string {
114
+ return `${baseId}-thumb-${idx}`;
115
+ }
116
+
117
+ const isRange = computed(() => Array.isArray(props.modelValue));
118
+
119
+ const valuesArray = computed<number[]>(() =>
120
+ Array.isArray(props.modelValue)
121
+ ? [props.modelValue[0], props.modelValue[1]]
122
+ : [props.modelValue],
123
+ );
124
+
125
+ const hasErrors = computed(() => props.errors.length > 0);
126
+
127
+ const computedStatus = computed(() => {
128
+ if (props.status) return props.status;
129
+ if (hasErrors.value) return 'error';
130
+ return undefined;
131
+ });
132
+
133
+ const describedBy = computed(() => {
134
+ const ids: string[] = [];
135
+ if (props.hint) ids.push(hintId);
136
+ if (hasErrors.value) ids.push(errorsId);
137
+ return ids.length > 0 ? ids.join(' ') : undefined;
138
+ });
139
+
140
+ function thumbPercent(value: number): number {
141
+ if (props.max === props.min) return 0;
142
+ return ((value - props.min) / (props.max - props.min)) * 100;
143
+ }
144
+
145
+ function thumbAriaLabel(idx: number): string {
146
+ if (!isRange.value) return props.label;
147
+ return idx === 0
148
+ ? `${props.label} (lower bound)`
149
+ : `${props.label} (upper bound)`;
150
+ }
151
+
152
+ function ariaValueText(idx: number, value: number): string {
153
+ if (!isRange.value) return String(value);
154
+ return idx === 0 ? `Lower bound: ${value}` : `Upper bound: ${value}`;
155
+ }
156
+
157
+ const trackFillStyle = computed(() => {
158
+ if (isRange.value && Array.isArray(props.modelValue)) {
159
+ const [v0, v1] = props.modelValue;
160
+ return {
161
+ '--pui-slider-fill-start': `${thumbPercent(v0)}%`,
162
+ '--pui-slider-fill-end': `${thumbPercent(v1)}%`,
163
+ };
164
+ }
165
+ const v = props.modelValue as number;
166
+ return {
167
+ '--pui-slider-fill-start': '0%',
168
+ '--pui-slider-fill-end': `${thumbPercent(v)}%`,
169
+ };
170
+ });
171
+
172
+ function clamp(value: number, lo: number, hi: number): number {
173
+ if (value < lo) return lo;
174
+ if (value > hi) return hi;
175
+ return value;
176
+ }
177
+
178
+ function snapToStep(value: number): number {
179
+ if (props.step <= 0) return value;
180
+ const stepped =
181
+ Math.round((value - props.min) / props.step) * props.step + props.min;
182
+ const decimals = (props.step.toString().split('.')[1] ?? '').length;
183
+ return decimals > 0
184
+ ? Number.parseFloat(stepped.toFixed(decimals))
185
+ : stepped;
186
+ }
187
+
188
+ function setValueFor(idx: number, raw: number): void {
189
+ const next = snapToStep(clamp(raw, props.min, props.max));
190
+ if (isRange.value && Array.isArray(props.modelValue)) {
191
+ const [v0, v1] = props.modelValue;
192
+ if (idx === 0) {
193
+ emit('update:modelValue', [Math.min(next, v1), v1]);
194
+ } else {
195
+ emit('update:modelValue', [v0, Math.max(next, v0)]);
196
+ }
197
+ } else {
198
+ emit('update:modelValue', next);
199
+ }
200
+ }
201
+
202
+ function currentValue(idx: number): number {
203
+ if (Array.isArray(props.modelValue)) {
204
+ return props.modelValue[idx] ?? props.min;
205
+ }
206
+ return props.modelValue;
207
+ }
208
+
209
+ function onKeydown(event: KeyboardEvent, idx: number): void {
210
+ if (props.disabled) return;
211
+ const current = currentValue(idx);
212
+ let next: number;
213
+ switch (event.key) {
214
+ case 'ArrowRight':
215
+ case 'ArrowUp':
216
+ next = current + props.step;
217
+ break;
218
+ case 'ArrowLeft':
219
+ case 'ArrowDown':
220
+ next = current - props.step;
221
+ break;
222
+ case 'PageUp':
223
+ next = current + props.step * 10;
224
+ break;
225
+ case 'PageDown':
226
+ next = current - props.step * 10;
227
+ break;
228
+ case 'Home':
229
+ next = props.min;
230
+ break;
231
+ case 'End':
232
+ next = props.max;
233
+ break;
234
+ default:
235
+ return;
236
+ }
237
+ event.preventDefault();
238
+ setValueFor(idx, next);
239
+ }
240
+
241
+ let dragIndex = -1;
242
+ let dragPointerId: number | null = null;
243
+
244
+ function valueFromPointer(event: PointerEvent): number {
245
+ const track = trackRef.value;
246
+ if (!track) return props.min;
247
+ const rect = track.getBoundingClientRect();
248
+ const x = event.clientX - rect.left;
249
+ const ratio = clamp(x / rect.width, 0, 1);
250
+ return props.min + ratio * (props.max - props.min);
251
+ }
252
+
253
+ function nearestThumb(target: number): number {
254
+ if (!isRange.value || !Array.isArray(props.modelValue)) return 0;
255
+ const [v0, v1] = props.modelValue;
256
+ return Math.abs(target - v0) <= Math.abs(target - v1) ? 0 : 1;
257
+ }
258
+
259
+ function onThumbPointerDown(event: PointerEvent, idx: number): void {
260
+ if (props.disabled) return;
261
+ if (event.button !== undefined && event.button !== 0) return;
262
+ dragIndex = idx;
263
+ dragPointerId = event.pointerId;
264
+ trackRef.value?.setPointerCapture(event.pointerId);
265
+ thumbRefs.value[idx]?.focus();
266
+ event.preventDefault();
267
+ }
268
+
269
+ function onTrackPointerDown(event: PointerEvent): void {
270
+ if (props.disabled) return;
271
+ if (event.button !== undefined && event.button !== 0) return;
272
+ // Skip thumb hits — the thumb's own pointerdown handles them.
273
+ if (event.target instanceof HTMLButtonElement) return;
274
+ const value = valueFromPointer(event);
275
+ const idx = nearestThumb(value);
276
+ setValueFor(idx, value);
277
+ dragIndex = idx;
278
+ dragPointerId = event.pointerId;
279
+ trackRef.value?.setPointerCapture(event.pointerId);
280
+ thumbRefs.value[idx]?.focus();
281
+ event.preventDefault();
282
+ }
283
+
284
+ function onPointerMove(event: PointerEvent): void {
285
+ if (dragIndex === -1) return;
286
+ if (dragPointerId !== null && event.pointerId !== dragPointerId) return;
287
+ const value = valueFromPointer(event);
288
+ setValueFor(dragIndex, value);
289
+ }
290
+
291
+ function onPointerUp(event: PointerEvent): void {
292
+ if (dragPointerId !== null && event.pointerId !== dragPointerId) return;
293
+ if (trackRef.value?.hasPointerCapture(event.pointerId)) {
294
+ trackRef.value.releasePointerCapture(event.pointerId);
295
+ }
296
+ dragIndex = -1;
297
+ dragPointerId = null;
298
+ }
299
+
300
+ onMounted(() => {
301
+ const track = trackRef.value;
302
+ if (!track) return;
303
+ track.addEventListener('pointerdown', onTrackPointerDown);
304
+ track.addEventListener('pointermove', onPointerMove);
305
+ track.addEventListener('pointerup', onPointerUp);
306
+ track.addEventListener('pointercancel', onPointerUp);
307
+ });
308
+
309
+ onBeforeUnmount(() => {
310
+ const track = trackRef.value;
311
+ if (!track) return;
312
+ track.removeEventListener('pointerdown', onTrackPointerDown);
313
+ track.removeEventListener('pointermove', onPointerMove);
314
+ track.removeEventListener('pointerup', onPointerUp);
315
+ track.removeEventListener('pointercancel', onPointerUp);
316
+ });
317
+ </script>
318
+
319
+ <style>
320
+ @import './slider.css';
321
+ </style>
@@ -0,0 +1,115 @@
1
+ @layer components {
2
+ /* ===== pui-slider (track + rail + fill + thumb buttons) =====
3
+ *
4
+ * Slider extends the locked Form-primitive scaffold with a
5
+ * horizontal track that hosts a faint background rail, a coloured
6
+ * fill segment representing the current value (or range), and one
7
+ * or two `<button role="slider">` thumbs. Each thumb is absolutely
8
+ * positioned via `inset-inline-start: <pct>%`; the controller
9
+ * (Astro) and Vue's reactive computed both write the same
10
+ * percentage so the visual is identical across both tiers.
11
+ *
12
+ * Layout rationale: rejected the native `<input type="range">`
13
+ * baseline for two reasons — (a) cross-browser thumb / track
14
+ * styling is famously inconsistent (each engine ships its own
15
+ * shadow DOM and pseudo-elements), and (b) range mode (two
16
+ * thumbs) is impossible without a custom layer. A
17
+ * button-thumb-on-a-track approach gives us full control over
18
+ * focus, ARIA, and dual-thumb crossing semantics.
19
+ */
20
+
21
+ .pui-slider-shell {
22
+ /* Allow the hidden form inputs to live next to the track without
23
+ * affecting layout. The track owns the visual surface. */
24
+ display: block;
25
+ width: 100%;
26
+ }
27
+
28
+ .pui-slider {
29
+ position: relative;
30
+ width: 100%;
31
+ height: 1.5rem;
32
+ /* Touch-friendly tap surface: the visual track is thin but the
33
+ * pointer-target spans the full element height. */
34
+ cursor: pointer;
35
+ user-select: none;
36
+ touch-action: none;
37
+ }
38
+
39
+ .pui-slider[data-disabled='true'] {
40
+ cursor: not-allowed;
41
+ opacity: 0.6;
42
+ }
43
+
44
+ .pui-slider__rail {
45
+ position: absolute;
46
+ inset-inline: 0;
47
+ inset-block: 50%;
48
+ transform: translateY(-50%);
49
+ height: 0.375rem;
50
+ background: var(--bg-clr-surface-2);
51
+ border-radius: var(--radius-pill, 999px);
52
+ }
53
+
54
+ .pui-slider__fill {
55
+ position: absolute;
56
+ inset-inline-start: var(--pui-slider-fill-start, 0%);
57
+ inset-inline-end: calc(100% - var(--pui-slider-fill-end, 0%));
58
+ inset-block: 50%;
59
+ transform: translateY(-50%);
60
+ height: 0.375rem;
61
+ background: var(--bg-clr-brand);
62
+ border-radius: var(--radius-pill, 999px);
63
+ }
64
+
65
+ .pui-field[data-status='error'] .pui-slider__fill {
66
+ background: var(--bg-clr-danger, var(--border-clr-danger));
67
+ }
68
+
69
+ .pui-field[data-status='success'] .pui-slider__fill {
70
+ background: var(--bg-clr-success, var(--border-clr-success));
71
+ }
72
+
73
+ /* Thumb: native button so it gets keyboard focus / Enter-Space
74
+ * activation for free. Translated -50% so its visual centre lands
75
+ * exactly on the percentage point. */
76
+ .pui-slider__thumb {
77
+ appearance: none;
78
+ position: absolute;
79
+ inset-block: 50%;
80
+ transform: translate(-50%, -50%);
81
+ width: 1.25rem;
82
+ height: 1.25rem;
83
+ padding: 0;
84
+ margin: 0;
85
+ border: var(--stroke-md) solid var(--border-clr-brand);
86
+ background: var(--bg-clr-surface);
87
+ border-radius: 50%;
88
+ cursor: grab;
89
+ transition:
90
+ box-shadow 0.12s ease,
91
+ border-color 0.12s ease;
92
+ }
93
+
94
+ .pui-slider__thumb:focus-visible {
95
+ outline: none;
96
+ box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.35);
97
+ }
98
+
99
+ .pui-slider__thumb:active {
100
+ cursor: grabbing;
101
+ }
102
+
103
+ .pui-slider__thumb[aria-disabled='true'] {
104
+ cursor: not-allowed;
105
+ opacity: 0.7;
106
+ }
107
+
108
+ .pui-field[data-status='error'] .pui-slider__thumb {
109
+ border-color: var(--border-clr-danger);
110
+ }
111
+
112
+ .pui-field[data-status='success'] .pui-slider__thumb {
113
+ border-color: var(--border-clr-success);
114
+ }
115
+ }
@@ -0,0 +1,75 @@
1
+ ---
2
+ import './switch.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ checked?: boolean;
10
+ disabled?: boolean;
11
+ hint?: string;
12
+ errors?: string[];
13
+ status?: 'error' | 'success';
14
+ }
15
+
16
+ const {
17
+ id,
18
+ label,
19
+ name,
20
+ checked = false,
21
+ disabled = false,
22
+ hint,
23
+ errors = [],
24
+ status,
25
+ ...rest
26
+ } = Astro.props;
27
+
28
+ const inputId = id ?? generateId('switch');
29
+ const hintId = `${inputId}-hint`;
30
+ const errorsId = `${inputId}-errors`;
31
+
32
+ const hasErrors = errors.length > 0;
33
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
34
+
35
+ const describedByIds: string[] = [];
36
+ if (hint) describedByIds.push(hintId);
37
+ if (hasErrors) describedByIds.push(errorsId);
38
+ const describedBy =
39
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
40
+ ---
41
+
42
+ <div class="pui-field" data-layout="inline" data-status={computedStatus}>
43
+ <input
44
+ class="pui-switch"
45
+ type="checkbox"
46
+ role="switch"
47
+ id={inputId}
48
+ name={name}
49
+ checked={checked}
50
+ disabled={disabled}
51
+ aria-checked={checked ? 'true' : 'false'}
52
+ aria-invalid={hasErrors ? 'true' : undefined}
53
+ aria-describedby={describedBy}
54
+ {...rest}
55
+ />
56
+ <label class="pui-field__label" for={inputId}>
57
+ {label}
58
+ </label>
59
+ {
60
+ hint && (
61
+ <p id={hintId} class="pui-field__hint">
62
+ {hint}
63
+ </p>
64
+ )
65
+ }
66
+ {
67
+ hasErrors && (
68
+ <ul id={errorsId} class="pui-field__hint" role="alert">
69
+ {errors.map((error) => (
70
+ <li>{error}</li>
71
+ ))}
72
+ </ul>
73
+ )
74
+ }
75
+ </div>
@@ -0,0 +1,89 @@
1
+ <template>
2
+ <div class="pui-field" data-layout="inline" :data-status="computedStatus">
3
+ <input
4
+ v-bind="$attrs"
5
+ :id="inputId"
6
+ class="pui-switch"
7
+ type="checkbox"
8
+ role="switch"
9
+ :name="name"
10
+ :checked="modelValue"
11
+ :disabled="disabled"
12
+ :aria-checked="modelValue ? 'true' : 'false'"
13
+ :aria-invalid="hasErrors ? 'true' : undefined"
14
+ :aria-describedby="describedBy"
15
+ @change="
16
+ emit(
17
+ 'update:modelValue',
18
+ ($event.target as HTMLInputElement).checked,
19
+ )
20
+ "
21
+ />
22
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
23
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
24
+ <ul
25
+ v-if="errors.length"
26
+ :id="errorsId"
27
+ class="pui-field__hint"
28
+ role="alert"
29
+ >
30
+ <li v-for="error in errors" :key="error">{{ error }}</li>
31
+ </ul>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue';
37
+ import { generateId } from '../../../utils/a11y/id.js';
38
+
39
+ defineOptions({ inheritAttrs: false });
40
+
41
+ const props = withDefaults(
42
+ defineProps<{
43
+ id?: string;
44
+ label: string;
45
+ modelValue?: boolean;
46
+ name?: string;
47
+ disabled?: boolean;
48
+ hint?: string;
49
+ errors?: string[];
50
+ status?: 'error' | 'success';
51
+ }>(),
52
+ {
53
+ id: undefined,
54
+ modelValue: false,
55
+ name: undefined,
56
+ disabled: false,
57
+ hint: undefined,
58
+ errors: () => [],
59
+ status: undefined,
60
+ },
61
+ );
62
+
63
+ const emit = defineEmits<{
64
+ 'update:modelValue': [value: boolean];
65
+ }>();
66
+
67
+ const inputId = props.id ?? generateId('switch');
68
+ const hintId = `${inputId}-hint`;
69
+ const errorsId = `${inputId}-errors`;
70
+
71
+ const hasErrors = computed(() => props.errors.length > 0);
72
+
73
+ const computedStatus = computed(() => {
74
+ if (props.status) return props.status;
75
+ if (hasErrors.value) return 'error';
76
+ return undefined;
77
+ });
78
+
79
+ const describedBy = computed(() => {
80
+ const ids: string[] = [];
81
+ if (props.hint) ids.push(hintId);
82
+ if (hasErrors.value) ids.push(errorsId);
83
+ return ids.length > 0 ? ids.join(' ') : undefined;
84
+ });
85
+ </script>
86
+
87
+ <style>
88
+ @import './switch.css';
89
+ </style>
@@ -0,0 +1,64 @@
1
+ @layer components {
2
+ .pui-switch {
3
+ appearance: none;
4
+ flex: none;
5
+ margin: 0;
6
+ width: 2.4em;
7
+ height: 1.4em;
8
+ background: var(--border-clr-base);
9
+ border: var(--stroke-md) solid var(--border-clr-strong);
10
+ border-radius: var(--radius-pill);
11
+ position: relative;
12
+ transition:
13
+ background 0.15s ease,
14
+ border-color 0.15s ease;
15
+ cursor: pointer;
16
+ }
17
+
18
+ .pui-switch::after {
19
+ content: '';
20
+ position: absolute;
21
+ top: 50%;
22
+ left: 2px;
23
+ width: 0.95em;
24
+ height: 0.95em;
25
+ background: var(--bg-clr-surface);
26
+ border: var(--stroke-sm) solid var(--border-clr-strong);
27
+ border-radius: 50%;
28
+ transform: translateY(-50%);
29
+ transition: left 0.15s ease;
30
+ }
31
+
32
+ .pui-switch:checked {
33
+ background: var(--bg-clr-brand);
34
+ }
35
+
36
+ .pui-switch:checked::after {
37
+ left: calc(100% - 0.95em - 2px);
38
+ }
39
+
40
+ .pui-switch:focus-visible {
41
+ outline: 3px solid var(--outline-clr-base);
42
+ outline-offset: 2px;
43
+ }
44
+
45
+ .pui-switch:disabled {
46
+ opacity: 0.5;
47
+ cursor: not-allowed;
48
+ }
49
+
50
+ /* Status-driven recolour from the parent pui-field wrapper. */
51
+ .pui-field[data-status='error'] .pui-switch {
52
+ border-color: var(--border-clr-danger);
53
+ }
54
+
55
+ .pui-field[data-status='success'] .pui-switch:checked {
56
+ background: var(--bg-clr-success, var(--bg-clr-brand));
57
+ }
58
+
59
+ /* Disable the cursor + label colour when the input is disabled. */
60
+ .pui-field:has(.pui-switch:disabled) .pui-field__label {
61
+ color: var(--text-clr-muted);
62
+ cursor: not-allowed;
63
+ }
64
+ }