@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,147 @@
1
+ ---
2
+ import './input-otp.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ /**
10
+ * Initial OTP string. Length matches `length` (Astro pads with empty
11
+ * characters or trims as needed before rendering slots).
12
+ */
13
+ value?: string;
14
+ /** Number of slots (default 6). */
15
+ length?: number;
16
+ /**
17
+ * Per-character validation pattern as a string (regex source). Default
18
+ * is digits-only `^[0-9]$`. Pass an alphanumeric pattern as a regex
19
+ * source string when the OTP code includes letters. The string is
20
+ * stored on a `data-pattern` attribute so the controller can rehydrate
21
+ * the regex post-mount.
22
+ */
23
+ pattern?: string;
24
+ disabled?: boolean;
25
+ hint?: string;
26
+ errors?: string[];
27
+ status?: 'error' | 'success';
28
+ }
29
+
30
+ const {
31
+ id,
32
+ label,
33
+ name,
34
+ value = '',
35
+ length = 6,
36
+ pattern,
37
+ disabled = false,
38
+ hint,
39
+ errors = [],
40
+ status,
41
+ ...rest
42
+ } = Astro.props;
43
+
44
+ const firstSlotId = id ?? generateId('input');
45
+ const hintId = `${firstSlotId}-hint`;
46
+ const errorsId = `${firstSlotId}-errors`;
47
+
48
+ const hasErrors = errors.length > 0;
49
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
50
+
51
+ const describedByIds: string[] = [];
52
+ if (hint) describedByIds.push(hintId);
53
+ if (hasErrors) describedByIds.push(errorsId);
54
+ const describedBy =
55
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
56
+
57
+ // Pad / trim seed value to the slot count.
58
+ const slots = Array.from({ length }, (_, i) => value[i] ?? '');
59
+ ---
60
+
61
+ <div class="pui-field" data-status={computedStatus}>
62
+ <label class="pui-field__label" for={firstSlotId}>
63
+ {label}
64
+ </label>
65
+ <div
66
+ class="pui-input-otp"
67
+ data-pui-input-otp
68
+ data-disabled={disabled ? 'true' : undefined}
69
+ data-pattern={pattern}
70
+ role="group"
71
+ >
72
+ {
73
+ slots.map((char, i) => (
74
+ <input
75
+ class="pui-input-otp__slot pui-input"
76
+ type="text"
77
+ inputmode="numeric"
78
+ autocomplete="one-time-code"
79
+ maxlength="1"
80
+ id={i === 0 ? firstSlotId : `${firstSlotId}-slot-${i + 1}`}
81
+ aria-label={`Digit ${i + 1} of ${length}`}
82
+ value={char}
83
+ disabled={disabled}
84
+ aria-invalid={i === 0 && hasErrors ? 'true' : undefined}
85
+ aria-describedby={i === 0 ? describedBy : undefined}
86
+ {...(i === 0 ? rest : {})}
87
+ />
88
+ ))
89
+ }
90
+ {
91
+ name && (
92
+ <input
93
+ class="pui-input-otp__hidden"
94
+ type="hidden"
95
+ name={name}
96
+ value={value}
97
+ />
98
+ )
99
+ }
100
+ </div>
101
+ {
102
+ hint && (
103
+ <p id={hintId} class="pui-field__hint">
104
+ {hint}
105
+ </p>
106
+ )
107
+ }
108
+ {
109
+ hasErrors && (
110
+ <ul id={errorsId} class="pui-field__hint" role="alert">
111
+ {errors.map((error) => (
112
+ <li>{error}</li>
113
+ ))}
114
+ </ul>
115
+ )
116
+ }
117
+ </div>
118
+
119
+ <script>
120
+ import { mountInputOTP } from '@pienter/ui/utils/controllers/otp.js';
121
+
122
+ // Hoisted module script — Astro de-dupes across instances.
123
+ function mountAll(): void {
124
+ const wrappers = document.querySelectorAll<HTMLElement>(
125
+ '[data-pui-input-otp]',
126
+ );
127
+ wrappers.forEach((wrapper) => {
128
+ if (wrapper.dataset.vInputOtpMounted === 'true') return;
129
+ let pattern: RegExp | undefined;
130
+ if (wrapper.dataset.pattern) {
131
+ try {
132
+ pattern = new RegExp(wrapper.dataset.pattern);
133
+ } catch {
134
+ pattern = undefined;
135
+ }
136
+ }
137
+ mountInputOTP(wrapper, { pattern });
138
+ wrapper.dataset.vInputOtpMounted = 'true';
139
+ });
140
+ }
141
+
142
+ if (document.readyState === 'loading') {
143
+ document.addEventListener('DOMContentLoaded', mountAll, { once: true });
144
+ } else {
145
+ mountAll();
146
+ }
147
+ </script>
@@ -0,0 +1,209 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="firstSlotId">{{ label }}</label>
4
+ <div
5
+ class="pui-input-otp"
6
+ :data-disabled="disabled ? 'true' : undefined"
7
+ role="group"
8
+ :aria-labelledby="undefined"
9
+ >
10
+ <input
11
+ v-for="i in length"
12
+ :id="i === 1 ? firstSlotId : `${firstSlotId}-slot-${i}`"
13
+ :key="i"
14
+ :ref="(el) => setSlotRef(el, i - 1)"
15
+ class="pui-input-otp__slot pui-input"
16
+ type="text"
17
+ inputmode="numeric"
18
+ autocomplete="one-time-code"
19
+ maxlength="1"
20
+ :aria-label="`Digit ${i} of ${length}`"
21
+ :value="slotChar(i - 1)"
22
+ :disabled="disabled"
23
+ :aria-invalid="hasErrors ? 'true' : undefined"
24
+ :aria-describedby="i === 1 ? describedBy : undefined"
25
+ @input="(e) => onSlotInput(e, i - 1)"
26
+ @keydown="(e) => onSlotKeydown(e, i - 1)"
27
+ @paste="(e) => onSlotPaste(e, i - 1)"
28
+ @focus="(e) => onSlotFocus(e)"
29
+ />
30
+ <input v-if="name" type="hidden" :name="name" :value="modelValue" />
31
+ </div>
32
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
33
+ <ul
34
+ v-if="errors.length"
35
+ :id="errorsId"
36
+ class="pui-field__hint"
37
+ role="alert"
38
+ >
39
+ <li v-for="error in errors" :key="error">{{ error }}</li>
40
+ </ul>
41
+ </div>
42
+ </template>
43
+
44
+ <script setup lang="ts">
45
+ import { computed, ref, type ComponentPublicInstance } from 'vue';
46
+ import { generateId } from '../../../utils/a11y/id.js';
47
+
48
+ defineOptions({ inheritAttrs: false });
49
+
50
+ const props = withDefaults(
51
+ defineProps<{
52
+ id?: string;
53
+ label: string;
54
+ /** Concatenated OTP string. Length always matches `length`; unfilled slots are empty characters. */
55
+ modelValue: string;
56
+ name?: string;
57
+ /** Number of slots (default 6). */
58
+ length?: number;
59
+ /**
60
+ * Per-character validation pattern. Default digits-only
61
+ * (`/^[0-9]$/`). Pass an alphanumeric pattern for OTP codes that
62
+ * include letters.
63
+ */
64
+ pattern?: RegExp;
65
+ disabled?: boolean;
66
+ hint?: string;
67
+ errors?: string[];
68
+ status?: 'error' | 'success';
69
+ }>(),
70
+ {
71
+ id: undefined,
72
+ name: undefined,
73
+ length: 6,
74
+ pattern: () => /^[0-9]$/,
75
+ disabled: false,
76
+ hint: undefined,
77
+ errors: () => [],
78
+ status: undefined,
79
+ },
80
+ );
81
+
82
+ const emit = defineEmits<{
83
+ 'update:modelValue': [value: string];
84
+ }>();
85
+
86
+ const slotRefs = ref<HTMLInputElement[]>([]);
87
+
88
+ function setSlotRef(
89
+ el: Element | ComponentPublicInstance | null,
90
+ index: number,
91
+ ): void {
92
+ if (el instanceof HTMLInputElement) {
93
+ slotRefs.value[index] = el;
94
+ }
95
+ }
96
+
97
+ const firstSlotId = props.id ?? generateId('input');
98
+ const hintId = `${firstSlotId}-hint`;
99
+ const errorsId = `${firstSlotId}-errors`;
100
+
101
+ const hasErrors = computed(() => props.errors.length > 0);
102
+
103
+ const computedStatus = computed(() => {
104
+ if (props.status) return props.status;
105
+ if (hasErrors.value) return 'error';
106
+ return undefined;
107
+ });
108
+
109
+ const describedBy = computed(() => {
110
+ const ids: string[] = [];
111
+ if (props.hint) ids.push(hintId);
112
+ if (hasErrors.value) ids.push(errorsId);
113
+ return ids.length > 0 ? ids.join(' ') : undefined;
114
+ });
115
+
116
+ function slotChar(idx: number): string {
117
+ return props.modelValue[idx] ?? '';
118
+ }
119
+
120
+ function setChar(idx: number, ch: string): void {
121
+ const arr = Array.from({ length: props.length }, (_, i) => slotChar(i));
122
+ arr[idx] = ch;
123
+ emit('update:modelValue', arr.join(''));
124
+ }
125
+
126
+ function focusSlot(idx: number): void {
127
+ const target = slotRefs.value[idx];
128
+ if (target) {
129
+ target.focus();
130
+ target.select();
131
+ }
132
+ }
133
+
134
+ function onSlotInput(event: Event, idx: number): void {
135
+ const target = event.target as HTMLInputElement;
136
+ const raw = target.value;
137
+ if (raw.length === 0) {
138
+ setChar(idx, '');
139
+ return;
140
+ }
141
+ // Multi-char input (paste fallback): take last char, validate.
142
+ const ch = raw.length > 1 ? (raw[raw.length - 1] ?? '') : raw;
143
+ if (!props.pattern.test(ch)) {
144
+ target.value = slotChar(idx); // restore previous
145
+ return;
146
+ }
147
+ target.value = ch;
148
+ setChar(idx, ch);
149
+ if (idx < props.length - 1) {
150
+ focusSlot(idx + 1);
151
+ }
152
+ }
153
+
154
+ function onSlotKeydown(event: KeyboardEvent, idx: number): void {
155
+ const target = event.target as HTMLInputElement;
156
+
157
+ if (event.key === 'Backspace') {
158
+ if (target.value.length === 0 && idx > 0) {
159
+ event.preventDefault();
160
+ setChar(idx - 1, '');
161
+ focusSlot(idx - 1);
162
+ } else if (target.value.length > 0) {
163
+ event.preventDefault();
164
+ setChar(idx, '');
165
+ }
166
+ return;
167
+ }
168
+
169
+ if (event.key === 'ArrowLeft' && idx > 0) {
170
+ event.preventDefault();
171
+ focusSlot(idx - 1);
172
+ return;
173
+ }
174
+
175
+ if (event.key === 'ArrowRight' && idx < props.length - 1) {
176
+ event.preventDefault();
177
+ focusSlot(idx + 1);
178
+ return;
179
+ }
180
+ }
181
+
182
+ function onSlotPaste(event: ClipboardEvent, idx: number): void {
183
+ const data = event.clipboardData?.getData('text') ?? '';
184
+ if (!data) return;
185
+ event.preventDefault();
186
+ const valid = Array.from(data).filter((ch) => props.pattern.test(ch));
187
+ if (valid.length === 0) return;
188
+ const arr = Array.from({ length: props.length }, (_, i) => slotChar(i));
189
+ let cursor = idx;
190
+ for (const ch of valid) {
191
+ if (cursor >= props.length) break;
192
+ arr[cursor] = ch;
193
+ cursor += 1;
194
+ }
195
+ emit('update:modelValue', arr.join(''));
196
+ const landingIdx = Math.min(cursor, props.length - 1);
197
+ focusSlot(landingIdx);
198
+ }
199
+
200
+ function onSlotFocus(event: FocusEvent): void {
201
+ if (event.target instanceof HTMLInputElement) {
202
+ event.target.select();
203
+ }
204
+ }
205
+ </script>
206
+
207
+ <style>
208
+ @import './input-otp.css';
209
+ </style>
@@ -0,0 +1,52 @@
1
+ @layer components {
2
+ /* ===== pui-input-otp (row of N single-character inputs) =====
3
+ *
4
+ * InputOTP extends the locked Form-primitive scaffold with an
5
+ * inline-flex row of N native `<input maxlength="1">` slots. Each
6
+ * slot inherits the shared `.pui-input` rule (sizing / padding /
7
+ * focus-ring / status recolour) for visual parity with TextInput
8
+ * and NumberField; the per-component overrides here narrow the
9
+ * width to a square box and centre the typed character so the
10
+ * code reads as one unit.
11
+ *
12
+ * Layout rationale: rejected the "single underline-bordered row"
13
+ * alternative because per-slot rectangles match shadcn / Reka
14
+ * conventions and make focus more legible (each slot's
15
+ * `:focus-visible` ring is independently obvious to keyboard
16
+ * users).
17
+ */
18
+
19
+ .pui-input-otp {
20
+ display: inline-flex;
21
+ align-items: center;
22
+ gap: var(--space-2xs);
23
+ }
24
+
25
+ .pui-input-otp[data-disabled='true'] {
26
+ opacity: 0.7;
27
+ }
28
+
29
+ .pui-input-otp__slot {
30
+ /* Inherits sizing, padding, focus-ring, disabled, and status
31
+ * recolour from the shared .pui-input rule (text-input.css). The
32
+ * overrides here narrow the slot to a square box and centre the
33
+ * typed character. */
34
+ width: 2.75rem;
35
+ text-align: center;
36
+ padding-inline: 0;
37
+ font-variant-numeric: tabular-nums;
38
+ font-size: var(--step-1, 1.125rem);
39
+ /* Slightly tighter min-height so a 6-slot row fits common form widths. */
40
+ line-height: 1.2;
41
+ }
42
+
43
+ /* Status states inherited from .pui-field — recolour every slot when
44
+ * the parent carries `data-status`. */
45
+ .pui-field[data-status='error'] .pui-input-otp__slot {
46
+ border-color: var(--border-clr-danger);
47
+ }
48
+
49
+ .pui-field[data-status='success'] .pui-input-otp__slot {
50
+ border-color: var(--border-clr-success);
51
+ }
52
+ }
@@ -0,0 +1,13 @@
1
+ ---
2
+ import './label.css';
3
+
4
+ interface Props {
5
+ for?: string;
6
+ }
7
+
8
+ const { for: htmlFor, ...rest } = Astro.props;
9
+ ---
10
+
11
+ <label class="pui-field__label" for={htmlFor} {...rest}>
12
+ <slot />
13
+ </label>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <label class="pui-field__label" :for="props.for">
3
+ <slot />
4
+ </label>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ for?: string;
11
+ }>(),
12
+ {
13
+ for: undefined,
14
+ },
15
+ );
16
+ </script>
17
+
18
+ <style>
19
+ @import './label.css';
20
+ </style>
@@ -0,0 +1,11 @@
1
+ /*
2
+ * Label is a near-empty stylesheet. The actual `.pui-field__label` rules
3
+ * live in `components/form/text-input/text-input.css` (the shared form-primitive
4
+ * scaffold). Label exposes the same class as a standalone component for
5
+ * use cases where the form-primitive components' built-in label is not
6
+ * suitable (e.g., labels associated with custom groups or with multiple
7
+ * controls). Importing this file re-imports the scaffold so a
8
+ * standalone Label dragged into an Astro page that does not also use
9
+ * TextInput / Textarea / Select still gets the typography rules.
10
+ */
11
+ @import '../text-input/text-input.css';
@@ -0,0 +1,142 @@
1
+ ---
2
+ import './number-field.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ /**
10
+ * Initial numeric value. Astro renders the value once at SSR time;
11
+ * dynamic updates are handled by the consumer's own scripts (see
12
+ * the consumer-wires-their-own-script pattern in CONVENTIONS) or
13
+ * by the increment/decrement buttons via `mountNumberField`.
14
+ */
15
+ value?: number | string;
16
+ min?: number;
17
+ max?: number;
18
+ step?: number | string;
19
+ placeholder?: string;
20
+ required?: boolean;
21
+ disabled?: boolean;
22
+ hint?: string;
23
+ errors?: string[];
24
+ status?: 'error' | 'success';
25
+ }
26
+
27
+ const {
28
+ id,
29
+ label,
30
+ name,
31
+ value,
32
+ min,
33
+ max,
34
+ step,
35
+ placeholder,
36
+ required = false,
37
+ disabled = false,
38
+ hint,
39
+ errors = [],
40
+ status,
41
+ ...rest
42
+ } = Astro.props;
43
+
44
+ const inputId = id ?? generateId('input');
45
+ const hintId = `${inputId}-hint`;
46
+ const errorsId = `${inputId}-errors`;
47
+
48
+ const hasErrors = errors.length > 0;
49
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
50
+
51
+ const describedByIds: string[] = [];
52
+ if (hint) describedByIds.push(hintId);
53
+ if (hasErrors) describedByIds.push(errorsId);
54
+ const describedBy =
55
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
56
+ ---
57
+
58
+ <div class="pui-field" data-status={computedStatus}>
59
+ <label class="pui-field__label" for={inputId}>
60
+ {label}
61
+ </label>
62
+ <div class="pui-number-field" data-pui-number-field>
63
+ <input
64
+ class="pui-number-field__input pui-input"
65
+ type="number"
66
+ id={inputId}
67
+ name={name}
68
+ min={min}
69
+ max={max}
70
+ step={step}
71
+ placeholder={placeholder}
72
+ required={required}
73
+ disabled={disabled}
74
+ value={value}
75
+ aria-invalid={hasErrors ? 'true' : undefined}
76
+ aria-describedby={describedBy}
77
+ {...rest}
78
+ />
79
+ <div class="pui-number-field__steppers">
80
+ <button
81
+ type="button"
82
+ class="pui-number-field__step"
83
+ data-direction="increment"
84
+ aria-label="Increase"
85
+ disabled={disabled}
86
+ >
87
+ <span aria-hidden="true">+</span>
88
+ </button>
89
+ <button
90
+ type="button"
91
+ class="pui-number-field__step"
92
+ data-direction="decrement"
93
+ aria-label="Decrease"
94
+ disabled={disabled}
95
+ >
96
+ <span aria-hidden="true">&#x2212;</span>
97
+ </button>
98
+ </div>
99
+ </div>
100
+ {
101
+ hint && (
102
+ <p id={hintId} class="pui-field__hint">
103
+ {hint}
104
+ </p>
105
+ )
106
+ }
107
+ {
108
+ hasErrors && (
109
+ <ul id={errorsId} class="pui-field__hint" role="alert">
110
+ {errors.map((error) => (
111
+ <li>{error}</li>
112
+ ))}
113
+ </ul>
114
+ )
115
+ }
116
+ </div>
117
+
118
+ <script>
119
+ import { mountNumberField } from '@pienter/ui/utils/controllers/number-field.js';
120
+
121
+ // Hoisted module script — Astro de-dupes this across every NumberField
122
+ // instance on the page. Mount the controller on each [data-pui-number-field]
123
+ // wrapper present at DOM-ready. See CONVENTIONS § hoisted-vs-inline rule.
124
+ function mountAll(): void {
125
+ const wrappers = document.querySelectorAll<HTMLElement>(
126
+ '[data-pui-number-field]',
127
+ );
128
+ wrappers.forEach((wrapper) => {
129
+ // Idempotency guard: skip wrappers that have already been mounted
130
+ // (e.g. on Astro view transitions / partial re-renders).
131
+ if (wrapper.dataset.vNumberFieldMounted === 'true') return;
132
+ mountNumberField(wrapper);
133
+ wrapper.dataset.vNumberFieldMounted = 'true';
134
+ });
135
+ }
136
+
137
+ if (document.readyState === 'loading') {
138
+ document.addEventListener('DOMContentLoaded', mountAll, { once: true });
139
+ } else {
140
+ mountAll();
141
+ }
142
+ </script>