@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,508 @@
1
+ <template>
2
+ <div ref="fieldRef" class="pui-field" :data-status="computedStatus">
3
+ <label v-if="label" class="pui-field__label" :for="inputId">{{
4
+ label
5
+ }}</label>
6
+ <input
7
+ v-bind="$attrs"
8
+ :id="inputId"
9
+ ref="inputRef"
10
+ class="pui-input pui-combobox__input"
11
+ type="text"
12
+ role="combobox"
13
+ :name="name"
14
+ :value="displayValue"
15
+ :placeholder="placeholder"
16
+ :disabled="disabled"
17
+ :required="required"
18
+ :autocomplete="autocomplete"
19
+ :popovertarget="listboxId"
20
+ :aria-expanded="isOpen ? 'true' : 'false'"
21
+ :aria-controls="listboxId"
22
+ :aria-activedescendant="activeOptionDomId || undefined"
23
+ aria-autocomplete="list"
24
+ :aria-invalid="hasErrors ? 'true' : undefined"
25
+ :aria-describedby="describedBy"
26
+ @input="onInput"
27
+ @focus="onFocus"
28
+ @blur="onBlur"
29
+ @keydown="onKeydown"
30
+ @click="onClick"
31
+ />
32
+ <ul
33
+ :id="listboxId"
34
+ ref="listboxRef"
35
+ class="pui-combobox__listbox"
36
+ role="listbox"
37
+ :aria-label="label || placeholder || 'Options'"
38
+ >
39
+ <!--
40
+ ARIA APG combobox/listbox pattern: keyboard interaction is owned
41
+ by the <input role="combobox"> above via aria-activedescendant
42
+ (Arrow/Home/End/Enter handled in onKeydown), so the option rows
43
+ are intentionally NOT focusable (no tabindex) and carry no per-row
44
+ key handlers. The @click / @mouseenter here are pointer affordances
45
+ that mirror the already-complete keyboard path. These three rules
46
+ assume a self-contained interactive element and false-positive on
47
+ this pattern; the keyboard behaviour they ask for already exists at
48
+ the combobox level. See W3C WAI-ARIA APG "Combobox".
49
+ -->
50
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/interactive-supports-focus, vuejs-accessibility/mouse-events-have-key-events -->
51
+ <li
52
+ v-for="(opt, idx) in filteredOptions"
53
+ :id="`${listboxId}-opt-${idx}`"
54
+ :key="opt.value"
55
+ class="pui-combobox__option"
56
+ role="option"
57
+ :aria-selected="modelValue === opt.value ? 'true' : 'false'"
58
+ :aria-disabled="opt.disabled ? 'true' : undefined"
59
+ :data-active="activeIndex === idx ? 'true' : undefined"
60
+ :data-disabled="opt.disabled ? 'true' : undefined"
61
+ @mousedown.prevent
62
+ @click="onOptionClick(opt)"
63
+ @mouseenter="onOptionMouseEnter(idx)"
64
+ >
65
+ {{ opt.label }}
66
+ </li>
67
+ <li
68
+ v-if="filteredOptions.length === 0"
69
+ class="pui-combobox__empty"
70
+ role="presentation"
71
+ >
72
+ {{ emptyMessage }}
73
+ </li>
74
+ </ul>
75
+ <p v-if="hint" :id="hintId" class="pui-field__hint">{{ hint }}</p>
76
+ <ul
77
+ v-if="errors.length"
78
+ :id="errorsId"
79
+ class="pui-field__hint"
80
+ role="alert"
81
+ >
82
+ <li v-for="error in errors" :key="error">{{ error }}</li>
83
+ </ul>
84
+ </div>
85
+ </template>
86
+
87
+ <script setup lang="ts">
88
+ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
89
+ import {
90
+ mountPopover,
91
+ type PopoverControl,
92
+ type Placement,
93
+ } from '../../../utils/controllers/popover.js';
94
+ import { generateId } from '../../../utils/a11y/id.js';
95
+
96
+ defineOptions({ inheritAttrs: false });
97
+
98
+ interface ComboboxOption {
99
+ value: string;
100
+ label: string;
101
+ disabled?: boolean;
102
+ }
103
+
104
+ const props = withDefaults(
105
+ defineProps<{
106
+ /**
107
+ * Optional id for the input element. Auto-generated if omitted.
108
+ * The listbox id is derived from this so the
109
+ * `aria-controls` / `popovertarget` association is stable.
110
+ */
111
+ id?: string;
112
+ /**
113
+ * Optional visible label rendered above the input. The label
114
+ * sits inside the `.pui-field` scaffold and is associated with
115
+ * the input via `for=<inputId>`. Omit for label-less variants
116
+ * where the consumer supplies their own labelling externally.
117
+ */
118
+ label?: string;
119
+ /**
120
+ * Selected option value (one of `:options[].value`). The input's
121
+ * displayed text is the matching option's label; an empty
122
+ * string clears the selection.
123
+ */
124
+ modelValue?: string;
125
+ /**
126
+ * Form-control name; submitted with the form when the input
127
+ * carries a real selection.
128
+ */
129
+ name?: string;
130
+ /**
131
+ * The list of options to display. Each option needs a unique
132
+ * `value` and a human-readable `label`; the optional `disabled`
133
+ * flag mutes the option and skips it in keyboard navigation.
134
+ */
135
+ options: ComboboxOption[];
136
+ /**
137
+ * Placeholder shown when no option is selected. Doubles as the
138
+ * `aria-label` fallback on the listbox when no `:label` is set.
139
+ */
140
+ placeholder?: string;
141
+ /** Disables the input (and thus the popover). */
142
+ disabled?: boolean;
143
+ /** Marks the input as required for form submission. */
144
+ required?: boolean;
145
+ /** Native autocomplete value forwarded to the input. */
146
+ autocomplete?: string;
147
+ /** Hint text rendered under the input via the `.pui-field__hint`. */
148
+ hint?: string;
149
+ /**
150
+ * Validation errors. Renders as a list under the input with
151
+ * `role="alert"` and toggles `aria-invalid="true"` plus the
152
+ * `data-status="error"` styling on `.pui-field`.
153
+ */
154
+ errors?: string[];
155
+ /**
156
+ * Explicit status override (`'error'` | `'success'`). When omitted
157
+ * but `errors.length > 0`, status falls back to `'error'`.
158
+ */
159
+ status?: 'error' | 'success';
160
+ /**
161
+ * Optional custom filter. Default: case-insensitive substring
162
+ * match on the option label. Receives the option and the
163
+ * current input string; returns `true` to keep the option.
164
+ */
165
+ filter?: (option: ComboboxOption, query: string) => boolean;
166
+ /**
167
+ * Empty-state message shown inside the listbox when filtering
168
+ * yields no matches. Default: "No results".
169
+ */
170
+ emptyMessage?: string;
171
+ /** Initial popover placement; default `'bottom-start'`. */
172
+ placement?: Placement;
173
+ /** Pixel offset between input and listbox; default `4`. */
174
+ offset?: number;
175
+ }>(),
176
+ {
177
+ id: undefined,
178
+ label: undefined,
179
+ modelValue: undefined,
180
+ name: undefined,
181
+ placeholder: undefined,
182
+ disabled: false,
183
+ required: false,
184
+ autocomplete: undefined,
185
+ hint: undefined,
186
+ errors: () => [],
187
+ status: undefined,
188
+ filter: undefined,
189
+ emptyMessage: 'No results',
190
+ placement: 'bottom-start',
191
+ offset: 4,
192
+ },
193
+ );
194
+
195
+ const emit = defineEmits<{
196
+ 'update:modelValue': [value: string];
197
+ 'open-change': [value: boolean];
198
+ }>();
199
+
200
+ const inputId = props.id ?? generateId('combobox');
201
+ const listboxId = `${inputId}-listbox`;
202
+ const hintId = `${inputId}-hint`;
203
+ const errorsId = `${inputId}-errors`;
204
+
205
+ const inputRef = ref<HTMLInputElement | null>(null);
206
+ const listboxRef = ref<HTMLUListElement | null>(null);
207
+ const fieldRef = ref<HTMLElement | null>(null);
208
+
209
+ const isOpen = ref(false);
210
+ const query = ref('');
211
+ // `null` means "no active option" — used for both the initial
212
+ // open state (no row highlighted until ArrowDown) and after the
213
+ // user types something that filters out the previously-active row.
214
+ const activeIndex = ref<number | null>(null);
215
+ // Tracks whether the user is actively typing (vs. just focusing the
216
+ // input). When typing, `displayValue` returns the raw query so the
217
+ // user sees their own keystrokes; when not typing, it returns the
218
+ // label of the currently-selected option.
219
+ const isTyping = ref(false);
220
+
221
+ let control: PopoverControl | null = null;
222
+
223
+ const hasErrors = computed(() => props.errors.length > 0);
224
+
225
+ const computedStatus = computed(() => {
226
+ if (props.status) return props.status;
227
+ if (hasErrors.value) return 'error';
228
+ return undefined;
229
+ });
230
+
231
+ const describedBy = computed(() => {
232
+ const ids: string[] = [];
233
+ if (props.hint) ids.push(hintId);
234
+ if (hasErrors.value) ids.push(errorsId);
235
+ return ids.length > 0 ? ids.join(' ') : undefined;
236
+ });
237
+
238
+ // The selected option's label is the canonical `displayValue` when
239
+ // the user is not actively typing. While typing, the raw query is
240
+ // shown so the input echoes the user's keystrokes; the selection
241
+ // only "snaps" back into the input on blur or Enter.
242
+ const selectedOption = computed(() =>
243
+ props.modelValue
244
+ ? props.options.find((o) => o.value === props.modelValue)
245
+ : undefined,
246
+ );
247
+
248
+ const displayValue = computed(() => {
249
+ if (isTyping.value) return query.value;
250
+ return selectedOption.value?.label ?? '';
251
+ });
252
+
253
+ function defaultFilter(option: ComboboxOption, q: string): boolean {
254
+ if (!q) return true;
255
+ return option.label.toLowerCase().includes(q.toLowerCase());
256
+ }
257
+
258
+ const filteredOptions = computed(() => {
259
+ const fn = props.filter ?? defaultFilter;
260
+ // While typing, filter against the live query; while idle,
261
+ // show all options (the listbox is collapsed in that state
262
+ // anyway for the no-selection case).
263
+ const q = isTyping.value ? query.value : '';
264
+ return props.options.filter((opt) => fn(opt, q));
265
+ });
266
+
267
+ const activeOptionDomId = computed(() => {
268
+ if (activeIndex.value == null) return null;
269
+ return `${listboxId}-opt-${activeIndex.value}`;
270
+ });
271
+
272
+ function open(): void {
273
+ if (props.disabled) return;
274
+ if (!control) return;
275
+ control.show();
276
+ }
277
+
278
+ function close(): void {
279
+ if (!control) return;
280
+ control.hide();
281
+ }
282
+
283
+ function moveActive(direction: 1 | -1): void {
284
+ const len = filteredOptions.value.length;
285
+ if (len === 0) {
286
+ activeIndex.value = null;
287
+ return;
288
+ }
289
+ // Skip disabled options on arrow navigation. Loop at most `len`
290
+ // times so the cursor never falls into an infinite loop on a
291
+ // listbox that contains only disabled rows.
292
+ let idx = activeIndex.value;
293
+ for (let i = 0; i < len; i += 1) {
294
+ if (idx == null) {
295
+ idx = direction === 1 ? 0 : len - 1;
296
+ } else {
297
+ idx = (idx + direction + len) % len;
298
+ }
299
+ if (!filteredOptions.value[idx]?.disabled) {
300
+ activeIndex.value = idx;
301
+ scrollActiveIntoView();
302
+ return;
303
+ }
304
+ }
305
+ // All options disabled — leave activeIndex as it was.
306
+ }
307
+
308
+ function scrollActiveIntoView(): void {
309
+ if (activeIndex.value == null || !listboxRef.value) return;
310
+ const el = listboxRef.value.querySelector<HTMLElement>(
311
+ `#${CSS.escape(`${listboxId}-opt-${activeIndex.value}`)}`,
312
+ );
313
+ el?.scrollIntoView({ block: 'nearest' });
314
+ }
315
+
316
+ function selectOption(opt: ComboboxOption): void {
317
+ if (opt.disabled) return;
318
+ emit('update:modelValue', opt.value);
319
+ query.value = opt.label;
320
+ isTyping.value = false;
321
+ close();
322
+ // Restore focus to the input so the user can keep tabbing through
323
+ // the form. The browser doesn't auto-restore focus when the
324
+ // popover closes via JS show/hide.
325
+ inputRef.value?.focus();
326
+ }
327
+
328
+ function onInput(event: Event): void {
329
+ const target = event.target as HTMLInputElement;
330
+ query.value = target.value;
331
+ isTyping.value = true;
332
+ // First filtered match becomes the active option so Enter has
333
+ // an obvious target. Skip disabled options.
334
+ const firstEnabled = filteredOptions.value.findIndex((o) => !o.disabled);
335
+ activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
336
+ if (!isOpen.value) open();
337
+ }
338
+
339
+ function onFocus(): void {
340
+ if (props.disabled) return;
341
+ // Open on focus — the canonical combobox UX is "click into the
342
+ // field, see the options". Don't reset the query: a refocused
343
+ // combobox should preserve whatever the user had typed.
344
+ open();
345
+ }
346
+
347
+ function onBlur(): void {
348
+ // Reset typing flag so the displayValue snaps back to the
349
+ // selected option's label. The popover's light-dismiss handles
350
+ // closure when focus leaves the surface.
351
+ isTyping.value = false;
352
+ query.value = '';
353
+ }
354
+
355
+ function onClick(): void {
356
+ // A click on an already-focused input also opens the listbox —
357
+ // covers the second-click-to-reopen case after the user has
358
+ // dismissed via Escape and refocused.
359
+ if (!isOpen.value) open();
360
+ }
361
+
362
+ function onOptionClick(opt: ComboboxOption): void {
363
+ selectOption(opt);
364
+ }
365
+
366
+ function onOptionMouseEnter(idx: number): void {
367
+ if (filteredOptions.value[idx]?.disabled) return;
368
+ activeIndex.value = idx;
369
+ }
370
+
371
+ function onKeydown(event: KeyboardEvent): void {
372
+ switch (event.key) {
373
+ case 'ArrowDown':
374
+ event.preventDefault();
375
+ if (!isOpen.value) {
376
+ open();
377
+ // Defer active-index move until after the popover opens so
378
+ // the listbox content is visible to scroll into view.
379
+ const firstEnabled = filteredOptions.value.findIndex(
380
+ (o) => !o.disabled,
381
+ );
382
+ activeIndex.value = firstEnabled >= 0 ? firstEnabled : null;
383
+ } else {
384
+ moveActive(1);
385
+ }
386
+ break;
387
+ case 'ArrowUp':
388
+ event.preventDefault();
389
+ if (!isOpen.value) {
390
+ open();
391
+ const lastEnabled = [...filteredOptions.value]
392
+ .map((o, i) => ({ o, i }))
393
+ .reverse()
394
+ .find(({ o }) => !o.disabled);
395
+ activeIndex.value = lastEnabled ? lastEnabled.i : null;
396
+ } else {
397
+ moveActive(-1);
398
+ }
399
+ break;
400
+ case 'Home':
401
+ if (!isOpen.value) return;
402
+ event.preventDefault();
403
+ {
404
+ const firstEnabled = filteredOptions.value.findIndex(
405
+ (o) => !o.disabled,
406
+ );
407
+ if (firstEnabled >= 0) {
408
+ activeIndex.value = firstEnabled;
409
+ scrollActiveIntoView();
410
+ }
411
+ }
412
+ break;
413
+ case 'End':
414
+ if (!isOpen.value) return;
415
+ event.preventDefault();
416
+ {
417
+ let lastEnabled = -1;
418
+ for (let i = filteredOptions.value.length - 1; i >= 0; i -= 1) {
419
+ if (!filteredOptions.value[i]?.disabled) {
420
+ lastEnabled = i;
421
+ break;
422
+ }
423
+ }
424
+ if (lastEnabled >= 0) {
425
+ activeIndex.value = lastEnabled;
426
+ scrollActiveIntoView();
427
+ }
428
+ }
429
+ break;
430
+ case 'Enter':
431
+ if (!isOpen.value) return;
432
+ if (activeIndex.value == null) return;
433
+ event.preventDefault();
434
+ {
435
+ const opt = filteredOptions.value[activeIndex.value];
436
+ if (opt) selectOption(opt);
437
+ }
438
+ break;
439
+ case 'Escape':
440
+ // The native Popover API's light-dismiss already handles
441
+ // Escape, but we explicitly close so the framework's
442
+ // `isOpen` state flips synchronously and the next keystroke
443
+ // doesn't see a stale "open" listbox.
444
+ if (isOpen.value) {
445
+ event.preventDefault();
446
+ close();
447
+ // Reset typing state so the selected option's label
448
+ // re-appears in the input — Escape is "abandon current
449
+ // edit, restore previous selection".
450
+ isTyping.value = false;
451
+ query.value = '';
452
+ }
453
+ break;
454
+ case 'Tab':
455
+ // Tab leaves the combobox; let the browser's default
456
+ // tabbing run and let the popover's light-dismiss close
457
+ // the listbox. Don't preventDefault — that would trap
458
+ // focus inside the field.
459
+ if (isOpen.value) close();
460
+ break;
461
+ default:
462
+ break;
463
+ }
464
+ }
465
+
466
+ onMounted(() => {
467
+ if (!listboxRef.value || !inputRef.value) return;
468
+ control = mountPopover(listboxRef.value, {
469
+ // Anchor the listbox to the input itself so positioning
470
+ // tracks the input's box (the input is the visual reference,
471
+ // not the wrapper).
472
+ anchor: inputRef.value,
473
+ placement: props.placement,
474
+ offset: props.offset,
475
+ // `modal: false` → `popover="auto"` so the browser handles
476
+ // outside-click / focus-out / Escape light-dismiss. The
477
+ // listbox closes naturally when the user tabs or clicks away.
478
+ onOpen: () => {
479
+ isOpen.value = true;
480
+ emit('open-change', true);
481
+ },
482
+ onClose: () => {
483
+ isOpen.value = false;
484
+ activeIndex.value = null;
485
+ emit('open-change', false);
486
+ },
487
+ });
488
+ });
489
+
490
+ // Watch model changes from the consumer and reset typing state so
491
+ // the input mirrors the new selection's label cleanly.
492
+ watch(
493
+ () => props.modelValue,
494
+ () => {
495
+ isTyping.value = false;
496
+ query.value = '';
497
+ },
498
+ );
499
+
500
+ onBeforeUnmount(() => {
501
+ control?.teardown();
502
+ control = null;
503
+ });
504
+ </script>
505
+
506
+ <style>
507
+ @import './combobox.css';
508
+ </style>
@@ -0,0 +1,110 @@
1
+ @layer components {
2
+ /* ===== pui-combobox =====
3
+ *
4
+ * Combobox is built on top of the form-primitive scaffold (`.pui-field`
5
+ * with `.pui-field__label` / `.pui-field__hint` / status-driven recolour)
6
+ * owned by `text-input.css`. The input itself uses the shared
7
+ * `.pui-input` class so its border, padding, focus ring, and
8
+ * disabled state match TextInput / Textarea exactly.
9
+ *
10
+ * The listbox is a separate element (a `<ul popover="auto">`) the
11
+ * controller positions via `mountPopover`. CSS resets the
12
+ * user-agent popover defaults (`margin: 0; border: 0; inset: auto`)
13
+ * and supplies the surface look — the Popover API places the
14
+ * element in the top layer; inline `left`/`top` from the controller
15
+ * drive the actual placement.
16
+ */
17
+
18
+ /* The input is `.pui-input` (form-primitive surface). Add a tiny
19
+ marker class for any combobox-specific tweaks; today there are
20
+ none — the input looks like a plain TextInput, which is the
21
+ correct affordance per the WAI-ARIA combobox pattern. */
22
+ .pui-combobox__input {
23
+ /* Hook for future combobox-specific styling (e.g. trailing
24
+ chevron icon). v1 keeps the input visually identical to
25
+ a TextInput. */
26
+ }
27
+
28
+ .pui-combobox__listbox {
29
+ /* Reset native popover defaults — the user-agent centers a
30
+ popover at the viewport origin via inset/margin/padding
31
+ defaults that interfere with anchor positioning. */
32
+ margin: 0;
33
+ border: 0;
34
+ inset: auto;
35
+ list-style: none;
36
+
37
+ /* Surface — uses the lightweight surface treatment shared
38
+ with DropdownMenu (extension-of-input feel rather than
39
+ standalone content). */
40
+ background: var(--bg-clr-surface);
41
+ border: var(--stroke-sm) solid var(--border-clr-base);
42
+ border-radius: var(--radius-sm);
43
+ box-shadow: var(--shadow-spark);
44
+ color: var(--text-clr-base);
45
+
46
+ /* Sizing — matches a typical combobox listbox: at least the
47
+ input's width (the controller anchors it to the input,
48
+ so it inherits no width naturally), capped to keep long
49
+ option lists scrollable. */
50
+ min-width: 12rem;
51
+ max-height: 16rem;
52
+ overflow-y: auto;
53
+ padding: var(--space-3xs);
54
+
55
+ /* Position is set by the controller via inline `left`/`top`. */
56
+ position: fixed;
57
+
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 1px;
61
+ }
62
+
63
+ .pui-combobox__option {
64
+ /* Reset list-item styles — options are clickable rows, not
65
+ bulleted entries. */
66
+ appearance: none;
67
+ background: transparent;
68
+ border: 0;
69
+ text-align: start;
70
+ width: 100%;
71
+ padding: var(--space-2xs) var(--space-xs);
72
+ font: inherit;
73
+ font-size: var(--step--1);
74
+ color: var(--text-clr-base);
75
+ border-radius: var(--radius-xs);
76
+ cursor: pointer;
77
+
78
+ /* Allow icon + label + optional trailing meta inside the
79
+ option without consumers fighting the layout. v1 only
80
+ renders the label string, but the flex container keeps
81
+ the door open. */
82
+ display: flex;
83
+ align-items: center;
84
+ gap: var(--space-2xs);
85
+ }
86
+
87
+ .pui-combobox__option[data-active='true']:not([data-disabled='true']) {
88
+ background: var(--bg-clr-surface-2);
89
+ }
90
+
91
+ .pui-combobox__option[aria-selected='true'] {
92
+ /* Selected option gets a subtle accent so users see which
93
+ option is currently the model value, distinct from the
94
+ hover/active highlight (which can land on any row). */
95
+ font-weight: var(--fw-semibold);
96
+ }
97
+
98
+ .pui-combobox__option[data-disabled='true'] {
99
+ color: var(--text-clr-muted);
100
+ cursor: not-allowed;
101
+ opacity: 0.6;
102
+ }
103
+
104
+ .pui-combobox__empty {
105
+ padding: var(--space-2xs) var(--space-xs);
106
+ color: var(--text-clr-muted);
107
+ font-size: var(--step--1);
108
+ font-style: italic;
109
+ }
110
+ }