@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,153 @@
1
+ ---
2
+ import './tags-input.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 tags array. Astro renders the pill DOM and seeds the
11
+ * controller via `data-initial-tags` so post-mount `mountTagsInput`
12
+ * stays in sync without re-reading attributes.
13
+ */
14
+ value?: string[];
15
+ placeholder?: string;
16
+ disabled?: boolean;
17
+ hint?: string;
18
+ errors?: string[];
19
+ status?: 'error' | 'success';
20
+ maxTags?: number;
21
+ separators?: string[];
22
+ }
23
+
24
+ const {
25
+ id,
26
+ label,
27
+ name,
28
+ value = [],
29
+ placeholder,
30
+ disabled = false,
31
+ hint,
32
+ errors = [],
33
+ status,
34
+ maxTags,
35
+ separators,
36
+ ...rest
37
+ } = Astro.props;
38
+
39
+ const inputId = id ?? generateId('input');
40
+ const hintId = `${inputId}-hint`;
41
+ const errorsId = `${inputId}-errors`;
42
+
43
+ const hasErrors = errors.length > 0;
44
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
45
+
46
+ const describedByIds: string[] = [];
47
+ if (hint) describedByIds.push(hintId);
48
+ if (hasErrors) describedByIds.push(errorsId);
49
+ const describedBy =
50
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
51
+
52
+ const maxReached = maxTags !== undefined && value.length >= maxTags;
53
+ ---
54
+
55
+ <div class="pui-field" data-status={computedStatus}>
56
+ <label class="pui-field__label" for={inputId}>
57
+ {label}
58
+ </label>
59
+ <div
60
+ class="pui-tags-input"
61
+ data-pui-tags-input
62
+ data-initial-tags={JSON.stringify(value)}
63
+ data-max-tags={maxTags !== undefined ? String(maxTags) : undefined}
64
+ data-separators={separators ? JSON.stringify(separators) : undefined}
65
+ data-disabled={disabled ? 'true' : undefined}
66
+ >
67
+ {
68
+ value.map((tag) => (
69
+ <span class="pui-tags-input__tag" data-tag-value={tag}>
70
+ <span class="pui-tags-input__tag-label">{tag}</span>
71
+ <button
72
+ type="button"
73
+ class="pui-tags-input__tag-remove"
74
+ aria-label={`Remove tag ${tag}`}
75
+ data-tags-remove
76
+ disabled={disabled}
77
+ >
78
+ <span aria-hidden="true">&times;</span>
79
+ </button>
80
+ </span>
81
+ ))
82
+ }
83
+ <input
84
+ class="pui-tags-input__entry"
85
+ type="text"
86
+ id={inputId}
87
+ name={name}
88
+ placeholder={placeholder}
89
+ disabled={disabled || maxReached}
90
+ aria-invalid={hasErrors ? 'true' : undefined}
91
+ aria-describedby={describedBy}
92
+ {...rest}
93
+ />
94
+ </div>
95
+ {
96
+ hint && (
97
+ <p id={hintId} class="pui-field__hint">
98
+ {hint}
99
+ </p>
100
+ )
101
+ }
102
+ {
103
+ hasErrors && (
104
+ <ul id={errorsId} class="pui-field__hint" role="alert">
105
+ {errors.map((error) => (
106
+ <li>{error}</li>
107
+ ))}
108
+ </ul>
109
+ )
110
+ }
111
+ </div>
112
+
113
+ <script>
114
+ import { mountTagsInput } from '@pienter/ui/utils/controllers/tags-input.js';
115
+
116
+ // Hoisted module script — Astro de-dupes across instances. Mount on
117
+ // every [data-pui-tags-input] wrapper present at DOM-ready.
118
+ function mountAll(): void {
119
+ const wrappers = document.querySelectorAll<HTMLElement>(
120
+ '[data-pui-tags-input]',
121
+ );
122
+ wrappers.forEach((wrapper) => {
123
+ if (wrapper.dataset.vTagsInputMounted === 'true') return;
124
+ const maxTagsRaw = wrapper.dataset.maxTags;
125
+ const maxTags =
126
+ maxTagsRaw === undefined ? undefined : Number.parseInt(maxTagsRaw, 10);
127
+ let separators: string[] | undefined;
128
+ if (wrapper.dataset.separators) {
129
+ try {
130
+ const parsed: unknown = JSON.parse(wrapper.dataset.separators);
131
+ if (Array.isArray(parsed)) {
132
+ separators = parsed.filter(
133
+ (v): v is string => typeof v === 'string',
134
+ );
135
+ }
136
+ } catch {
137
+ separators = undefined;
138
+ }
139
+ }
140
+ mountTagsInput(wrapper, {
141
+ maxTags: Number.isFinite(maxTags) ? maxTags : undefined,
142
+ separators,
143
+ });
144
+ wrapper.dataset.vTagsInputMounted = 'true';
145
+ });
146
+ }
147
+
148
+ if (document.readyState === 'loading') {
149
+ document.addEventListener('DOMContentLoaded', mountAll, { once: true });
150
+ } else {
151
+ mountAll();
152
+ }
153
+ </script>
@@ -0,0 +1,207 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
4
+ <!--
5
+ onWrapperClick only forwards a click on the empty chrome of the field
6
+ to the real <input> below (a larger pointer target). That input is a
7
+ native, keyboard-focusable control reached directly via Tab, so no
8
+ keyboard equivalent belongs on this non-interactive wrapper: the div
9
+ is a layout container, not a control, and must not take a role/handler
10
+ of its own. Both rules false-positive on this click-to-focus affordance.
11
+ -->
12
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
13
+ <div
14
+ class="pui-tags-input"
15
+ :data-disabled="disabled ? 'true' : undefined"
16
+ @click="onWrapperClick"
17
+ >
18
+ <span
19
+ v-for="(tag, idx) in modelValue"
20
+ :key="`${tag}-${idx}`"
21
+ class="pui-tags-input__tag"
22
+ :data-tag-value="tag"
23
+ >
24
+ <span class="pui-tags-input__tag-label">{{ tag }}</span>
25
+ <button
26
+ type="button"
27
+ class="pui-tags-input__tag-remove"
28
+ :aria-label="`Remove tag ${tag}`"
29
+ :disabled="disabled"
30
+ @click.stop="removeTag(idx)"
31
+ >
32
+ <span aria-hidden="true">&times;</span>
33
+ </button>
34
+ </span>
35
+ <input
36
+ v-bind="$attrs"
37
+ :id="inputId"
38
+ ref="entryRef"
39
+ v-model="entryText"
40
+ class="pui-tags-input__entry"
41
+ type="text"
42
+ :name="name"
43
+ :placeholder="placeholder"
44
+ :disabled="disabled || maxReached"
45
+ :aria-invalid="hasErrors ? 'true' : undefined"
46
+ :aria-describedby="describedBy"
47
+ @keydown="onKeydown"
48
+ @blur="onBlur"
49
+ />
50
+ </div>
51
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
52
+ <ul
53
+ v-if="errors.length"
54
+ :id="errorsId"
55
+ class="pui-field__hint"
56
+ role="alert"
57
+ >
58
+ <li v-for="error in errors" :key="error">{{ error }}</li>
59
+ </ul>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { computed, ref } from 'vue';
65
+ import { generateId } from '../../../utils/a11y/id.js';
66
+
67
+ defineOptions({ inheritAttrs: false });
68
+
69
+ const props = withDefaults(
70
+ defineProps<{
71
+ id?: string;
72
+ label: string;
73
+ /** Current tags array. Vue emits `update:modelValue` on every add / remove. */
74
+ modelValue: string[];
75
+ name?: string;
76
+ placeholder?: string;
77
+ disabled?: boolean;
78
+ hint?: string;
79
+ errors?: string[];
80
+ status?: 'error' | 'success';
81
+ /**
82
+ * Maximum number of tags. Beyond this, the entry input is
83
+ * disabled (matching the controller's add-no-op behaviour).
84
+ */
85
+ maxTags?: number;
86
+ /**
87
+ * Characters or `event.key` names that finalize a tag. Default
88
+ * `[',', 'Enter']`.
89
+ */
90
+ separators?: string[];
91
+ }>(),
92
+ {
93
+ id: undefined,
94
+ name: undefined,
95
+ placeholder: undefined,
96
+ disabled: false,
97
+ hint: undefined,
98
+ errors: () => [],
99
+ status: undefined,
100
+ maxTags: undefined,
101
+ separators: () => [',', 'Enter'],
102
+ },
103
+ );
104
+
105
+ const emit = defineEmits<{
106
+ 'update:modelValue': [tags: string[]];
107
+ blur: [];
108
+ }>();
109
+
110
+ const entryRef = ref<HTMLInputElement | null>(null);
111
+ const entryText = ref('');
112
+
113
+ const inputId = props.id ?? generateId('input');
114
+ const hintId = `${inputId}-hint`;
115
+ const errorsId = `${inputId}-errors`;
116
+
117
+ const hasErrors = computed(() => props.errors.length > 0);
118
+
119
+ const computedStatus = computed(() => {
120
+ if (props.status) return props.status;
121
+ if (hasErrors.value) return 'error';
122
+ return undefined;
123
+ });
124
+
125
+ const describedBy = computed(() => {
126
+ const ids: string[] = [];
127
+ if (props.hint) ids.push(hintId);
128
+ if (hasErrors.value) ids.push(errorsId);
129
+ return ids.length > 0 ? ids.join(' ') : undefined;
130
+ });
131
+
132
+ const maxReached = computed(
133
+ () =>
134
+ props.maxTags !== undefined && props.modelValue.length >= props.maxTags,
135
+ );
136
+
137
+ function isSeparator(event: KeyboardEvent): boolean {
138
+ for (const sep of props.separators) {
139
+ if (event.key === sep) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ function addTag(raw: string): boolean {
145
+ const value = raw.trim();
146
+ if (!value) return false;
147
+ if (maxReached.value) return false;
148
+ if (props.modelValue.includes(value)) return false;
149
+ emit('update:modelValue', [...props.modelValue, value]);
150
+ return true;
151
+ }
152
+
153
+ function removeTag(index: number): void {
154
+ if (props.disabled) return;
155
+ if (index < 0 || index >= props.modelValue.length) return;
156
+ const next = props.modelValue.filter((_, i) => i !== index);
157
+ emit('update:modelValue', next);
158
+ // Return focus to the entry input so the keyboard user keeps typing.
159
+ entryRef.value?.focus();
160
+ }
161
+
162
+ function onKeydown(event: KeyboardEvent): void {
163
+ if (props.disabled) return;
164
+ if (isSeparator(event)) {
165
+ if (entryText.value.trim().length > 0) {
166
+ event.preventDefault();
167
+ if (addTag(entryText.value)) {
168
+ entryText.value = '';
169
+ }
170
+ }
171
+ return;
172
+ }
173
+ if (
174
+ event.key === 'Backspace' &&
175
+ entryText.value === '' &&
176
+ props.modelValue.length > 0
177
+ ) {
178
+ event.preventDefault();
179
+ removeTag(props.modelValue.length - 1);
180
+ }
181
+ }
182
+
183
+ function onBlur(): void {
184
+ // Commit a pending typed tag on blur — matches the canonical
185
+ // Reka UI / shadcn TagsInput UX where focus-out is treated as a
186
+ // soft commit so the user doesn't lose what they were typing.
187
+ if (entryText.value.trim().length > 0) {
188
+ if (addTag(entryText.value)) {
189
+ entryText.value = '';
190
+ }
191
+ }
192
+ emit('blur');
193
+ }
194
+
195
+ function onWrapperClick(event: MouseEvent): void {
196
+ // Click anywhere in the wrapper (but not on a remove button) lands
197
+ // focus on the entry input — large click target for touch users.
198
+ const target = event.target;
199
+ if (target instanceof HTMLButtonElement) return;
200
+ if (target instanceof HTMLInputElement) return;
201
+ entryRef.value?.focus();
202
+ }
203
+ </script>
204
+
205
+ <style>
206
+ @import './tags-input.css';
207
+ </style>
@@ -0,0 +1,128 @@
1
+ @layer components {
2
+ /* ===== pui-tags-input (flex-wrapping pills + entry input) =====
3
+ *
4
+ * TagsInput extends the locked Form-primitive scaffold (the outer
5
+ * `.pui-field` wrapper from `text-input.css`) with a flex-wrapping
6
+ * row that hosts existing tag pills followed by a text input where
7
+ * the user types the next tag. The whole row reads as one
8
+ * integrated control: clicking anywhere lands focus on the entry
9
+ * input (handled by Vue / via the click handler in
10
+ * `mountTagsInput`).
11
+ *
12
+ * Layout rationale: rejected the "fixed-height pills + horizontally-
13
+ * scrolling row" alternative because vertical wrapping reads better
14
+ * on narrow form columns and avoids hiding tags off-screen. The
15
+ * entry input is the LAST DOM child so flex-wrap places it after
16
+ * the pills and pill removal naturally re-flows.
17
+ */
18
+
19
+ .pui-tags-input {
20
+ display: flex;
21
+ flex-wrap: wrap;
22
+ align-items: center;
23
+ gap: var(--space-2xs);
24
+ width: 100%;
25
+ padding: 0.4em 0.55em;
26
+ background: var(--bg-clr-surface);
27
+ border: var(--stroke-sm) solid var(--border-clr-base);
28
+ border-radius: var(--radius-sm);
29
+ color: var(--text-clr-base);
30
+ transition:
31
+ border-color 0.12s ease,
32
+ box-shadow 0.12s ease;
33
+ cursor: text;
34
+ }
35
+
36
+ .pui-tags-input:focus-within {
37
+ outline: none;
38
+ border-color: var(--border-clr-brand);
39
+ box-shadow: 0 0 0 3px hsl(from var(--outline-clr-base) h s l / 0.25);
40
+ }
41
+
42
+ .pui-tags-input[data-disabled='true'] {
43
+ background: var(--bg-clr-surface-2);
44
+ color: var(--text-clr-muted);
45
+ cursor: not-allowed;
46
+ }
47
+
48
+ /* Status states inherited from .pui-field — recolour to match
49
+ * .pui-input / .pui-textarea when the parent carries `data-status`. */
50
+ .pui-field[data-status='error'] .pui-tags-input {
51
+ border-color: var(--border-clr-danger);
52
+ }
53
+
54
+ .pui-field[data-status='success'] .pui-tags-input {
55
+ border-color: var(--border-clr-success);
56
+ }
57
+
58
+ /* Tag pill: rounded chip with label + remove button. */
59
+ .pui-tags-input__tag {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: var(--space-3xs);
63
+ padding: 0.15em 0.5em;
64
+ background: var(--bg-clr-surface-2);
65
+ border: var(--stroke-sm) solid var(--border-clr-base);
66
+ border-radius: var(--radius-pill, 999px);
67
+ font-size: var(--step--1);
68
+ line-height: 1.2;
69
+ max-width: 100%;
70
+ }
71
+
72
+ .pui-tags-input__tag-label {
73
+ overflow: hidden;
74
+ text-overflow: ellipsis;
75
+ white-space: nowrap;
76
+ }
77
+
78
+ .pui-tags-input__tag-remove {
79
+ appearance: none;
80
+ background: transparent;
81
+ border: 0;
82
+ padding: 0;
83
+ margin: 0;
84
+ color: inherit;
85
+ cursor: pointer;
86
+ font: inherit;
87
+ line-height: 1;
88
+ width: 1.25em;
89
+ height: 1.25em;
90
+ display: grid;
91
+ place-items: center;
92
+ border-radius: var(--radius-pill, 999px);
93
+ }
94
+
95
+ .pui-tags-input__tag-remove:hover:not(:disabled) {
96
+ background: var(--bg-clr-surface);
97
+ }
98
+
99
+ .pui-tags-input__tag-remove:focus-visible {
100
+ outline: 2px solid var(--outline-clr-base);
101
+ outline-offset: 1px;
102
+ }
103
+
104
+ .pui-tags-input__tag-remove:disabled {
105
+ cursor: not-allowed;
106
+ color: var(--text-clr-muted);
107
+ }
108
+
109
+ /* Entry input: borderless, flex-grows to fill the trailing slot.
110
+ * Inherits the .pui-input typography rules in spirit but does NOT
111
+ * carry the .pui-input class — the wrapper is the visual border /
112
+ * focus surface. */
113
+ .pui-tags-input__entry {
114
+ appearance: none;
115
+ background: transparent;
116
+ border: 0;
117
+ color: inherit;
118
+ font: inherit;
119
+ flex: 1;
120
+ min-width: 6rem;
121
+ padding: 0.25em 0;
122
+ outline: none;
123
+ }
124
+
125
+ .pui-tags-input__entry:disabled {
126
+ cursor: not-allowed;
127
+ }
128
+ }
@@ -0,0 +1,84 @@
1
+ ---
2
+ import './text-input.css';
3
+ import { generateId } from '../../../utils/a11y/id.js';
4
+
5
+ interface Props {
6
+ id?: string;
7
+ label: string;
8
+ name?: string;
9
+ type?: string;
10
+ value?: string;
11
+ autocomplete?: string;
12
+ required?: boolean;
13
+ disabled?: boolean;
14
+ placeholder?: string;
15
+ hint?: string;
16
+ errors?: string[];
17
+ status?: 'error' | 'success';
18
+ }
19
+
20
+ const {
21
+ id,
22
+ label,
23
+ name,
24
+ type = 'text',
25
+ value = '',
26
+ autocomplete,
27
+ required = false,
28
+ disabled = false,
29
+ placeholder,
30
+ hint,
31
+ errors = [],
32
+ status,
33
+ ...rest
34
+ } = Astro.props;
35
+
36
+ const inputId = id ?? generateId('input');
37
+ const hintId = `${inputId}-hint`;
38
+ const errorsId = `${inputId}-errors`;
39
+
40
+ const hasErrors = errors.length > 0;
41
+ const computedStatus = status ?? (hasErrors ? 'error' : undefined);
42
+
43
+ const describedByIds: string[] = [];
44
+ if (hint) describedByIds.push(hintId);
45
+ if (hasErrors) describedByIds.push(errorsId);
46
+ const describedBy =
47
+ describedByIds.length > 0 ? describedByIds.join(' ') : undefined;
48
+ ---
49
+
50
+ <div class="pui-field" data-status={computedStatus}>
51
+ <label class="pui-field__label" for={inputId}>
52
+ {label}
53
+ </label>
54
+ <input
55
+ class="pui-input"
56
+ id={inputId}
57
+ type={type}
58
+ name={name}
59
+ value={value}
60
+ autocomplete={autocomplete}
61
+ required={required}
62
+ disabled={disabled}
63
+ placeholder={placeholder}
64
+ aria-invalid={hasErrors ? 'true' : undefined}
65
+ aria-describedby={describedBy}
66
+ {...rest}
67
+ />
68
+ {
69
+ hint && (
70
+ <p id={hintId} class="pui-field__hint">
71
+ {hint}
72
+ </p>
73
+ )
74
+ }
75
+ {
76
+ hasErrors && (
77
+ <ul id={errorsId} class="pui-field__hint" role="alert">
78
+ {errors.map((error) => (
79
+ <li>{error}</li>
80
+ ))}
81
+ </ul>
82
+ )
83
+ }
84
+ </div>
@@ -0,0 +1,99 @@
1
+ <template>
2
+ <div class="pui-field" :data-status="computedStatus">
3
+ <label class="pui-field__label" :for="inputId">{{ label }}</label>
4
+ <input
5
+ v-bind="$attrs"
6
+ :id="inputId"
7
+ class="pui-input"
8
+ :type="type"
9
+ :name="name"
10
+ :value="modelValue"
11
+ :autocomplete="autocomplete"
12
+ :required="required"
13
+ :disabled="disabled"
14
+ :placeholder="placeholder"
15
+ :aria-invalid="hasErrors ? 'true' : undefined"
16
+ :aria-describedby="describedBy"
17
+ @input="
18
+ emit(
19
+ 'update:modelValue',
20
+ ($event.target as HTMLInputElement).value,
21
+ )
22
+ "
23
+ @blur="emit('blur')"
24
+ />
25
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
26
+ <ul
27
+ v-if="errors.length"
28
+ :id="errorsId"
29
+ class="pui-field__hint"
30
+ role="alert"
31
+ >
32
+ <li v-for="error in errors" :key="error">{{ error }}</li>
33
+ </ul>
34
+ </div>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ import { computed } from 'vue';
39
+ import { generateId } from '../../../utils/a11y/id.js';
40
+
41
+ defineOptions({ inheritAttrs: false });
42
+
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ id?: string;
46
+ label: string;
47
+ modelValue: string;
48
+ name?: string;
49
+ type?: string;
50
+ autocomplete?: string;
51
+ required?: boolean;
52
+ disabled?: boolean;
53
+ placeholder?: string;
54
+ hint?: string;
55
+ errors?: string[];
56
+ status?: 'error' | 'success';
57
+ }>(),
58
+ {
59
+ id: undefined,
60
+ name: undefined,
61
+ type: 'text',
62
+ autocomplete: undefined,
63
+ required: false,
64
+ disabled: false,
65
+ placeholder: undefined,
66
+ hint: undefined,
67
+ errors: () => [],
68
+ status: undefined,
69
+ },
70
+ );
71
+
72
+ const emit = defineEmits<{
73
+ 'update:modelValue': [value: string];
74
+ blur: [];
75
+ }>();
76
+
77
+ const inputId = props.id ?? generateId('input');
78
+ const hintId = `${inputId}-hint`;
79
+ const errorsId = `${inputId}-errors`;
80
+
81
+ const hasErrors = computed(() => props.errors.length > 0);
82
+
83
+ const computedStatus = computed(() => {
84
+ if (props.status) return props.status;
85
+ if (hasErrors.value) return 'error';
86
+ return undefined;
87
+ });
88
+
89
+ const describedBy = computed(() => {
90
+ const ids: string[] = [];
91
+ if (props.hint) ids.push(hintId);
92
+ if (hasErrors.value) ids.push(errorsId);
93
+ return ids.length > 0 ? ids.join(' ') : undefined;
94
+ });
95
+ </script>
96
+
97
+ <style>
98
+ @import './text-input.css';
99
+ </style>