@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.
- package/CHANGELOG.md +64 -0
- package/CONVENTIONS.md +1499 -0
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/components/action/button/Button.astro +95 -0
- package/components/action/button/Button.vue +92 -0
- package/components/action/button/IconButton.astro +86 -0
- package/components/action/button/IconButton.vue +85 -0
- package/components/action/button/button.css +194 -0
- package/components/action/toggle/Toggle.vue +38 -0
- package/components/action/toggle/toggle.css +77 -0
- package/components/action/toggle-group/ToggleGroup.vue +67 -0
- package/components/action/toggle-group/toggle-group.css +78 -0
- package/components/display/avatar/Avatar.astro +17 -0
- package/components/display/avatar/Avatar.vue +30 -0
- package/components/display/avatar/AvatarStack.astro +9 -0
- package/components/display/avatar/AvatarStack.vue +11 -0
- package/components/display/avatar/avatar.css +58 -0
- package/components/display/badge/Badge.astro +15 -0
- package/components/display/badge/Badge.vue +23 -0
- package/components/display/badge/badge.css +56 -0
- package/components/display/empty/Empty.astro +9 -0
- package/components/display/empty/Empty.vue +11 -0
- package/components/display/empty/empty.css +39 -0
- package/components/display/icon/Icon.astro +52 -0
- package/components/display/icon/Icon.vue +57 -0
- package/components/display/icon/icon.css +47 -0
- package/components/feedback/alert/Alert.astro +52 -0
- package/components/feedback/alert/Alert.vue +60 -0
- package/components/feedback/alert/alert.css +78 -0
- package/components/feedback/progress/Progress.astro +68 -0
- package/components/feedback/progress/Progress.vue +82 -0
- package/components/feedback/progress/progress.css +68 -0
- package/components/feedback/skeleton/Skeleton.astro +32 -0
- package/components/feedback/skeleton/Skeleton.vue +39 -0
- package/components/feedback/skeleton/skeleton.css +56 -0
- package/components/feedback/spinner/Spinner.astro +25 -0
- package/components/feedback/spinner/Spinner.vue +36 -0
- package/components/feedback/spinner/spinner.css +91 -0
- package/components/feedback/toast/Toast.astro +50 -0
- package/components/feedback/toast/Toast.vue +74 -0
- package/components/feedback/toast/toast.css +128 -0
- package/components/form/checkbox/Checkbox.astro +79 -0
- package/components/form/checkbox/Checkbox.vue +95 -0
- package/components/form/checkbox/checkbox.css +59 -0
- package/components/form/combobox/Combobox.vue +508 -0
- package/components/form/combobox/combobox.css +110 -0
- package/components/form/date-input/DateInput.astro +105 -0
- package/components/form/date-input/DateInput.vue +121 -0
- package/components/form/date-input/date-input.css +19 -0
- package/components/form/form/Form.astro +106 -0
- package/components/form/form/Form.vue +181 -0
- package/components/form/form/form.css +46 -0
- package/components/form/input-otp/InputOTP.astro +147 -0
- package/components/form/input-otp/InputOTP.vue +209 -0
- package/components/form/input-otp/input-otp.css +52 -0
- package/components/form/label/Label.astro +13 -0
- package/components/form/label/Label.vue +20 -0
- package/components/form/label/label.css +11 -0
- package/components/form/number-field/NumberField.astro +142 -0
- package/components/form/number-field/NumberField.vue +155 -0
- package/components/form/number-field/number-field.css +115 -0
- package/components/form/radio-group/RadioGroup.astro +105 -0
- package/components/form/radio-group/RadioGroup.vue +110 -0
- package/components/form/radio-group/radio-group.css +114 -0
- package/components/form/radio-group/types.ts +14 -0
- package/components/form/select/Segmented.vue +36 -0
- package/components/form/select/Select.astro +105 -0
- package/components/form/select/Select.vue +109 -0
- package/components/form/select/select.css +96 -0
- package/components/form/slider/Slider.astro +205 -0
- package/components/form/slider/Slider.vue +321 -0
- package/components/form/slider/slider.css +115 -0
- package/components/form/switch/Switch.astro +75 -0
- package/components/form/switch/Switch.vue +89 -0
- package/components/form/switch/switch.css +64 -0
- package/components/form/tags-input/TagsInput.astro +153 -0
- package/components/form/tags-input/TagsInput.vue +207 -0
- package/components/form/tags-input/tags-input.css +128 -0
- package/components/form/text-input/TextInput.astro +84 -0
- package/components/form/text-input/TextInput.vue +99 -0
- package/components/form/text-input/text-input.css +165 -0
- package/components/form/textarea/Textarea.astro +86 -0
- package/components/form/textarea/Textarea.vue +102 -0
- package/components/form/textarea/textarea.css +25 -0
- package/components/layout/accordion/Accordion.vue +59 -0
- package/components/layout/accordion/accordion.css +87 -0
- package/components/layout/card/Card.astro +13 -0
- package/components/layout/card/Card.vue +20 -0
- package/components/layout/card/card.css +55 -0
- package/components/layout/collapsible/Collapsible.vue +77 -0
- package/components/layout/collapsible/collapsible.css +76 -0
- package/components/layout/separator/Separator.astro +31 -0
- package/components/layout/separator/Separator.vue +33 -0
- package/components/layout/separator/separator.css +27 -0
- package/components/layout/table/DataTable.vue +127 -0
- package/components/layout/table/Table.astro +116 -0
- package/components/layout/table/Table.vue +146 -0
- package/components/layout/table/TableRow.vue +59 -0
- package/components/layout/table/table.css +201 -0
- package/components/layout/table/types.ts +35 -0
- package/components/layout/table/useTable.ts +7 -0
- package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
- package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
- package/components/navigation/breadcrumb/breadcrumb.css +37 -0
- package/components/navigation/navbar/Navbar.astro +62 -0
- package/components/navigation/navbar/Navbar.vue +50 -0
- package/components/navigation/navbar/navbar.css +77 -0
- package/components/navigation/pagination/Pagination.vue +107 -0
- package/components/navigation/pagination/pagination.css +53 -0
- package/components/navigation/sidebar/Sidebar.astro +132 -0
- package/components/navigation/sidebar/Sidebar.vue +174 -0
- package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
- package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
- package/components/navigation/sidebar/sidebar.css +303 -0
- package/components/navigation/sidebar/types.ts +72 -0
- package/components/navigation/tabs/Tabs.vue +84 -0
- package/components/navigation/tabs/tabs.css +39 -0
- package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
- package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
- package/components/overlay/alert-dialog/alert-dialog.css +57 -0
- package/components/overlay/command/Command.vue +356 -0
- package/components/overlay/command/command.css +179 -0
- package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
- package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
- package/components/overlay/modal/Modal.astro +66 -0
- package/components/overlay/modal/Modal.vue +85 -0
- package/components/overlay/modal/modal.css +60 -0
- package/components/overlay/popover/Popover.vue +113 -0
- package/components/overlay/popover/popover.css +53 -0
- package/components/overlay/sheet/Sheet.vue +88 -0
- package/components/overlay/sheet/sheet.css +108 -0
- package/components/overlay/tooltip/Tooltip.vue +210 -0
- package/components/overlay/tooltip/tooltip.css +50 -0
- package/composables/useUrlSort.ts +48 -0
- package/icons/alert-triangle.ts +1 -0
- package/icons/arrow-down.ts +1 -0
- package/icons/arrow-up-down.ts +5 -0
- package/icons/arrow-up.ts +1 -0
- package/icons/bell.ts +1 -0
- package/icons/check.ts +1 -0
- package/icons/chevron-down.ts +1 -0
- package/icons/chevron-left.ts +1 -0
- package/icons/chevron-right.ts +1 -0
- package/icons/chevron-up-down.ts +5 -0
- package/icons/chevron-up.ts +1 -0
- package/icons/circle-alert.ts +1 -0
- package/icons/circle-check.ts +1 -0
- package/icons/clipboard.ts +1 -0
- package/icons/download.ts +1 -0
- package/icons/edit.ts +1 -0
- package/icons/external-link.ts +1 -0
- package/icons/eye.ts +1 -0
- package/icons/file.ts +1 -0
- package/icons/filter.ts +1 -0
- package/icons/folder.ts +1 -0
- package/icons/image.ts +1 -0
- package/icons/inbox.ts +1 -0
- package/icons/index.ts +91 -0
- package/icons/info.ts +1 -0
- package/icons/layers.ts +1 -0
- package/icons/link-2.ts +1 -0
- package/icons/list.ts +1 -0
- package/icons/loader.ts +3 -0
- package/icons/menu.ts +1 -0
- package/icons/more-horizontal.ts +1 -0
- package/icons/more-vertical.ts +1 -0
- package/icons/plus-circle.ts +1 -0
- package/icons/plus.ts +1 -0
- package/icons/save.ts +1 -0
- package/icons/search.ts +1 -0
- package/icons/send.ts +1 -0
- package/icons/settings.ts +1 -0
- package/icons/tool.ts +1 -0
- package/icons/trash-2.ts +1 -0
- package/icons/trash.ts +1 -0
- package/icons/upload-cloud.ts +1 -0
- package/icons/upload.ts +1 -0
- package/icons/x.ts +1 -0
- package/package.json +150 -0
- package/styles/0-settings/colors.css +241 -0
- package/styles/0-settings/index.css +5 -0
- package/styles/0-settings/layout.css +52 -0
- package/styles/0-settings/motion.css +11 -0
- package/styles/0-settings/spacing.css +15 -0
- package/styles/0-settings/typography.css +37 -0
- package/styles/0-utils/index.css +1 -0
- package/styles/1-reset/index.css +1 -0
- package/styles/1-reset/reset.css +26 -0
- package/styles/2-base/base.css +42 -0
- package/styles/2-base/forms.css +23 -0
- package/styles/2-base/index.css +2 -0
- package/styles/3-layout/container.css +57 -0
- package/styles/3-layout/index.css +2 -0
- package/styles/3-layout/section.css +17 -0
- package/styles/5-utilities/accessibility.css +13 -0
- package/styles/5-utilities/index.css +2 -0
- package/styles/5-utilities/text.css +5 -0
- package/styles/main.css +8 -0
- package/styles/styles.d.ts +6 -0
- package/utils/a11y/focus.ts +68 -0
- package/utils/a11y/id.ts +10 -0
- package/utils/a11y/index.ts +9 -0
- package/utils/a11y/keyboard.ts +32 -0
- package/utils/a11y/live-region.ts +36 -0
- package/utils/controllers/dialog.ts +205 -0
- package/utils/controllers/disclosure.ts +117 -0
- package/utils/controllers/form.ts +524 -0
- package/utils/controllers/index.ts +39 -0
- package/utils/controllers/menu.ts +255 -0
- package/utils/controllers/number-field.ts +103 -0
- package/utils/controllers/otp.ts +252 -0
- package/utils/controllers/popover.ts +434 -0
- package/utils/controllers/sidebar.ts +610 -0
- package/utils/controllers/slider.ts +336 -0
- package/utils/controllers/tags-input.ts +255 -0
- package/utils/controllers/toast.ts +426 -0
- package/utils/dom/index.ts +1 -0
- package/utils/dom/scroll-lock.ts +48 -0
- package/utils/index.ts +3 -0
- package/utils/sort/index.ts +3 -0
- package/utils/sort/serialize.ts +19 -0
- package/utils/sort/state.ts +11 -0
- package/utils/sort/types.ts +15 -0
- package/utils/validation/form.ts +93 -0
- package/utils/validation/index.ts +13 -0
- 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
|
+
}
|