@dumiflex/wp-kit 0.3.0 → 0.5.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 +92 -51
- package/LICENSE +674 -674
- package/README.md +73 -76
- package/package.json +78 -77
- package/src/Avatar.vue +83 -83
- package/src/Badge.vue +97 -97
- package/src/Banner.vue +118 -118
- package/src/BookmarkButton.vue +27 -27
- package/src/BundleMeta.vue +51 -51
- package/src/Button.vue +103 -103
- package/src/Card.vue +68 -68
- package/src/Checkbox.vue +109 -107
- package/src/Chip.vue +163 -97
- package/src/ChoiceCards.vue +133 -133
- package/src/CloseButton.vue +69 -69
- package/src/CodeBlock.vue +146 -119
- package/src/ColorField.vue +188 -206
- package/src/ColorPicker.vue +201 -201
- package/src/Combobox.vue +331 -334
- package/src/ConfirmDialog.vue +165 -163
- package/src/ConfirmModal.vue +147 -147
- package/src/ConstraintMatrixPreview.vue +708 -708
- package/src/ContextMenu.vue +386 -379
- package/src/CopyButton.vue +156 -156
- package/src/DataTable.vue +293 -292
- package/src/DatePicker.vue +234 -234
- package/src/DescriptionList.vue +73 -73
- package/src/DiffView.vue +68 -68
- package/src/Disclosure.vue +166 -166
- package/src/DragHandle.vue +49 -49
- package/src/Drawer.vue +131 -131
- package/src/DropZone.vue +196 -196
- package/src/DropdownMenu.vue +72 -72
- package/src/EmptyState.vue +111 -111
- package/src/Field.vue +28 -28
- package/src/HsvPicker.vue +208 -208
- package/src/Icon.vue +39 -39
- package/src/IconPicker.vue +209 -209
- package/src/ImageGrid.vue +127 -125
- package/src/InlineEdit.vue +183 -183
- package/src/Input.vue +99 -99
- package/src/Kbd.vue +56 -56
- package/src/Lightbox.vue +176 -173
- package/src/ListFilter.vue +85 -85
- package/src/ListRow.vue +204 -204
- package/src/LoadingMark.vue +217 -161
- package/src/Logo.vue +60 -60
- package/src/Modal.vue +78 -76
- package/src/ModalShell.vue +109 -107
- package/src/Pagination.vue +106 -106
- package/src/Popover.vue +180 -171
- package/src/PostCard.vue +210 -211
- package/src/ProgressBar.vue +90 -90
- package/src/RadioGroup.vue +177 -177
- package/src/RangeSlider.vue +183 -183
- package/src/RelativeDate.vue +35 -35
- package/src/ResizeHandle.vue +143 -143
- package/src/SearchInput.vue +192 -199
- package/src/Select.vue +644 -641
- package/src/Separator.vue +54 -54
- package/src/Skeleton.vue +98 -82
- package/src/Slider.vue +198 -198
- package/src/SortableList.vue +260 -260
- package/src/SplitPane.vue +68 -68
- package/src/StarButton.vue +40 -40
- package/src/Stepper.vue +141 -141
- package/src/SupportLinks.vue +93 -93
- package/src/TagInput.vue +297 -300
- package/src/Textarea.vue +57 -57
- package/src/Timeline.vue +92 -92
- package/src/ToastCard.vue +119 -119
- package/src/ToastHost.vue +40 -40
- package/src/Toggle.vue +36 -36
- package/src/TokenizedValue.vue +310 -310
- package/src/Toolbar.vue +110 -110
- package/src/VirtualList.vue +87 -87
- package/src/VisuallyHidden.vue +29 -29
- package/src/WpButton.vue +75 -75
- package/src/WpCheck.vue +104 -102
- package/src/WpIconButton.vue +107 -107
- package/src/WpImageZoom.vue +544 -544
- package/src/WpNumber.vue +178 -183
- package/src/WpSegmented.vue +175 -175
- package/src/WpSelect.vue +93 -89
- package/src/WpSwitch.vue +163 -163
- package/src/WpTabs.vue +188 -188
- package/src/_modal-motion.css +33 -33
- package/src/_skeleton.css +50 -50
- package/src/axis-color.ts +42 -42
- package/src/button.css +169 -169
- package/src/calendar.ts +59 -59
- package/src/clipboard.ts +50 -50
- package/src/colour-presets.ts +12 -12
- package/src/diff.ts +51 -51
- package/src/drop-zone.ts +41 -41
- package/src/field.css +50 -0
- package/src/flip.ts +237 -237
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -99
- package/src/highlight.ts +199 -0
- package/src/hsv.ts +100 -100
- package/src/icon-catalog.ts +52 -52
- package/src/image-zoom.ts +93 -93
- package/src/kind-colour.ts +28 -0
- package/src/loading-mark-motion.ts +249 -0
- package/src/logo-paths.ts +21 -21
- package/src/matrix-axis.ts +91 -91
- package/src/pagination.ts +27 -27
- package/src/platform-keys.ts +29 -29
- package/src/rich-tokenize.ts +357 -357
- package/src/select-types.ts +32 -32
- package/src/slider-math.ts +45 -45
- package/src/sortable.ts +22 -22
- package/src/theme/fonts/OFL-Fraunces.txt +93 -93
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
- package/src/theme/fonts/OFL-Manrope.txt +93 -93
- package/src/theme/looks.css +428 -428
- package/src/theme/palettes.css +254 -254
- package/src/theme/tokens.css +2896 -2894
- package/src/tokens.ts +56 -56
- package/src/tooltip.css +43 -43
- package/src/tooltip.ts +146 -146
- package/src/useDialogFocus.ts +73 -73
- package/src/useGrowableField.ts +357 -357
- package/src/useModalKeyShield.ts +116 -116
- package/src/useToast.ts +48 -48
- package/src/wp-logo.svg +14 -14
- package/src/ChipToggle.vue +0 -92
- package/src/KindChip.vue +0 -53
- package/src/RatingBadge.vue +0 -42
package/src/TagInput.vue
CHANGED
|
@@ -1,300 +1,297 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Tag input: type a word and press Enter (or a comma) to add it as a chip;
|
|
4
|
-
* Backspace in the empty box takes the last chip off; each chip's ✕ removes
|
|
5
|
-
* it. Suggestions open under the box as you type and follow it on scroll
|
|
6
|
-
* (a kit Popover). Pasting "a, b, c" adds three. Works on the canvas
|
|
7
|
-
* (`size="sm"`) and in the manager (`md`).
|
|
8
|
-
*
|
|
9
|
-
* <TagInput v-model="tags" :suggestions="libraryTags" aria-label="Tags" />
|
|
10
|
-
* <TagInput v-model="axes" :suggestions="known" :allow-new="false" :normalize="(t) => t.toLowerCase()" />
|
|
11
|
-
*
|
|
12
|
-
* APG combobox with a list: the arrows move through the suggestions, Enter
|
|
13
|
-
* takes the lit one (or the typed text), Escape closes the list. A tag that
|
|
14
|
-
* is already there isn't added twice; a polite live line says what
|
|
15
|
-
* happened.
|
|
16
|
-
*/
|
|
17
|
-
import { computed, nextTick, ref, useId } from "vue";
|
|
18
|
-
import Popover from "./Popover.vue";
|
|
19
|
-
|
|
20
|
-
const props = withDefaults(defineProps<{
|
|
21
|
-
modelValue: string[];
|
|
22
|
-
suggestions?: string[] | undefined;
|
|
23
|
-
/** Take words that aren't in `suggestions`. */
|
|
24
|
-
allowNew?: boolean | undefined;
|
|
25
|
-
/** At most this many tags. */
|
|
26
|
-
max?: number | undefined;
|
|
27
|
-
/** Clean a tag before it is added (trim by default). Return "" to refuse it. */
|
|
28
|
-
normalize?: ((t: string) => string) | undefined;
|
|
29
|
-
placeholder?: string | undefined;
|
|
30
|
-
size?: "sm" | "md" | "lg" | undefined;
|
|
31
|
-
disabled?: boolean | undefined;
|
|
32
|
-
invalid?: boolean | undefined;
|
|
33
|
-
ariaLabel?: string | undefined;
|
|
34
|
-
/** How many suggestions to show at once. */
|
|
35
|
-
limit?: number | undefined;
|
|
36
|
-
}>(), {
|
|
37
|
-
suggestions: () => [], allowNew: true, max: undefined,
|
|
38
|
-
placeholder: "Add a tag", size: "md", ariaLabel: "Tags", limit: 8,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
const emit = defineEmits<{ (e: "update:modelValue", v: string[]): void }>();
|
|
42
|
-
|
|
43
|
-
const uid = useId();
|
|
44
|
-
const listId = `wp-tag-input-list-${uid}`;
|
|
45
|
-
const inputEl = ref<HTMLInputElement | null>(null);
|
|
46
|
-
const query = ref("");
|
|
47
|
-
/** The list may show: the box has focus and nobody closed the list since the last key. */
|
|
48
|
-
const wanted = ref(false);
|
|
49
|
-
const active = ref(-1);
|
|
50
|
-
const said = ref("");
|
|
51
|
-
|
|
52
|
-
const clean = (t: string): string => (props.normalize ? props.normalize(t) : t.trim());
|
|
53
|
-
const has = (t: string): boolean => props.modelValue.some((x) => x.toLowerCase() === t.toLowerCase());
|
|
54
|
-
const full = computed(() => props.max !== undefined && props.modelValue.length >= props.max);
|
|
55
|
-
|
|
56
|
-
const matches = computed(() => {
|
|
57
|
-
const q = query.value.trim().toLowerCase();
|
|
58
|
-
const pool = props.suggestions.filter((s) => !has(s));
|
|
59
|
-
const hit = q ? pool.filter((s) => s.toLowerCase().includes(q)) : pool;
|
|
60
|
-
// Words that start with the query first, then the rest.
|
|
61
|
-
return [...hit.filter((s) => s.toLowerCase().startsWith(q)), ...hit.filter((s) => !s.toLowerCase().startsWith(q))].slice(0, props.limit);
|
|
62
|
-
});
|
|
63
|
-
const open = computed(() => wanted.value && !full.value && !props.disabled && matches.value.length > 0);
|
|
64
|
-
const activeId = computed(() => (open.value && active.value >= 0 ? `${listId}-${active.value}` : undefined));
|
|
65
|
-
|
|
66
|
-
function announce(text: string): void {
|
|
67
|
-
said.value = "";
|
|
68
|
-
void nextTick(() => { said.value = text; });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function add(raw: string): boolean {
|
|
72
|
-
const t = clean(raw);
|
|
73
|
-
if (!t) return false;
|
|
74
|
-
if (has(t)) { announce(`${t} is already added`); return false; }
|
|
75
|
-
if (full.value) { announce(`At most ${props.max} tags`); return false; }
|
|
76
|
-
const known = props.suggestions.find((s) => s.toLowerCase() === t.toLowerCase());
|
|
77
|
-
if (!props.allowNew && !known) { announce(`${t} is not one of the choices`); return false; }
|
|
78
|
-
emit("update:modelValue", [...props.modelValue, known ?? t]);
|
|
79
|
-
announce(`Added ${known ?? t}`);
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function addMany(parts: string[]): void {
|
|
84
|
-
let list = [...props.modelValue];
|
|
85
|
-
const added: string[] = [];
|
|
86
|
-
for (const p of parts) {
|
|
87
|
-
const t = clean(p);
|
|
88
|
-
if (!t || list.some((x) => x.toLowerCase() === t.toLowerCase())) continue;
|
|
89
|
-
if (props.max !== undefined && list.length >= props.max) break;
|
|
90
|
-
const known = props.suggestions.find((s) => s.toLowerCase() === t.toLowerCase());
|
|
91
|
-
if (!props.allowNew && !known) continue;
|
|
92
|
-
list = [...list, known ?? t];
|
|
93
|
-
added.push(known ?? t);
|
|
94
|
-
}
|
|
95
|
-
if (added.length) {
|
|
96
|
-
emit("update:modelValue", list);
|
|
97
|
-
announce(`Added ${added.join(", ")}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function remove(i: number, refocus = false): void {
|
|
102
|
-
const t = props.modelValue[i];
|
|
103
|
-
if (t === undefined || props.disabled) return;
|
|
104
|
-
emit("update:modelValue", props.modelValue.filter((_, j) => j !== i));
|
|
105
|
-
announce(`Removed ${t}`);
|
|
106
|
-
if (refocus) inputEl.value?.focus();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function commit(): void {
|
|
110
|
-
if (open.value && active.value >= 0 && matches.value[active.value]) {
|
|
111
|
-
if (add(matches.value[active.value])) query.value = "";
|
|
112
|
-
} else if (query.value.trim() && add(query.value)) {
|
|
113
|
-
query.value = "";
|
|
114
|
-
}
|
|
115
|
-
active.value = -1;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function onKeydown(ev: KeyboardEvent): void {
|
|
119
|
-
if (props.disabled) return;
|
|
120
|
-
switch (ev.key) {
|
|
121
|
-
case "Enter":
|
|
122
|
-
if (!query.value.trim() && active.value < 0) return;
|
|
123
|
-
ev.preventDefault();
|
|
124
|
-
commit();
|
|
125
|
-
return;
|
|
126
|
-
case ",":
|
|
127
|
-
ev.preventDefault();
|
|
128
|
-
if (query.value.trim() && add(query.value)) query.value = "";
|
|
129
|
-
return;
|
|
130
|
-
case "ArrowDown":
|
|
131
|
-
if (!matches.value.length) return;
|
|
132
|
-
ev.preventDefault();
|
|
133
|
-
if (!wanted.value) { wanted.value = true; active.value = 0; return; }
|
|
134
|
-
active.value = (active.value + 1) % matches.value.length;
|
|
135
|
-
return;
|
|
136
|
-
case "ArrowUp":
|
|
137
|
-
if (!matches.value.length) return;
|
|
138
|
-
ev.preventDefault();
|
|
139
|
-
active.value = active.value <= 0 ? matches.value.length - 1 : active.value - 1;
|
|
140
|
-
return;
|
|
141
|
-
case "Backspace":
|
|
142
|
-
if (query.value === "" && props.modelValue.length) {
|
|
143
|
-
ev.preventDefault();
|
|
144
|
-
remove(props.modelValue.length - 1);
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
default:
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function onInput(): void {
|
|
152
|
-
wanted.value = true;
|
|
153
|
-
if (query.value.includes(",")) {
|
|
154
|
-
const parts = query.value.split(",");
|
|
155
|
-
query.value = parts.pop() ?? "";
|
|
156
|
-
addMany(parts);
|
|
157
|
-
}
|
|
158
|
-
active.value = -1;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function onPaste(ev: ClipboardEvent): void {
|
|
162
|
-
const text = ev.clipboardData?.getData("text") ?? "";
|
|
163
|
-
if (!/[,\n]/.test(text)) return;
|
|
164
|
-
ev.preventDefault();
|
|
165
|
-
addMany(text.split(/[,\n]/));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function pick(s: string): void {
|
|
169
|
-
if (add(s)) query.value = "";
|
|
170
|
-
active.value = -1;
|
|
171
|
-
inputEl.value?.focus();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function setOpen(v: boolean): void {
|
|
175
|
-
// The list closes itself on Escape or a click outside; reopen on typing.
|
|
176
|
-
if (!v) { wanted.value = false; active.value = -1; }
|
|
177
|
-
}
|
|
178
|
-
</script>
|
|
179
|
-
|
|
180
|
-
<!-- Popover's autofocus prop is off here: focus stays in the box you type in. -->
|
|
181
|
-
<!-- eslint-disable vuejs-accessibility/no-autofocus -->
|
|
182
|
-
<template>
|
|
183
|
-
<Popover :open="open" role="group" match-width :autofocus="false" :label="`${ariaLabel} suggestions`" @update:open="setOpen">
|
|
184
|
-
<template #trigger>
|
|
185
|
-
<!-- A click on the box's padding focuses the text field, which has the keys. -->
|
|
186
|
-
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
187
|
-
<div
|
|
188
|
-
class="wp-tag-input"
|
|
189
|
-
:
|
|
190
|
-
:data-
|
|
191
|
-
:data-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
:
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
:aria-
|
|
213
|
-
:aria-
|
|
214
|
-
:aria-
|
|
215
|
-
:aria-
|
|
216
|
-
:
|
|
217
|
-
:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
@
|
|
221
|
-
@
|
|
222
|
-
@
|
|
223
|
-
@
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
<!--
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
:
|
|
239
|
-
|
|
240
|
-
@
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
</
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.wp-tag-input[data-size="
|
|
258
|
-
.wp-tag-input
|
|
259
|
-
.wp-tag-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
.wp-tag-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
.wp-tag-input__remove
|
|
277
|
-
.wp-tag-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.wp-tag-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
.wp-tag-input { transition: none; }
|
|
299
|
-
}
|
|
300
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Tag input: type a word and press Enter (or a comma) to add it as a chip;
|
|
4
|
+
* Backspace in the empty box takes the last chip off; each chip's ✕ removes
|
|
5
|
+
* it. Suggestions open under the box as you type and follow it on scroll
|
|
6
|
+
* (a kit Popover). Pasting "a, b, c" adds three. Works on the canvas
|
|
7
|
+
* (`size="sm"`) and in the manager (`md`).
|
|
8
|
+
*
|
|
9
|
+
* <TagInput v-model="tags" :suggestions="libraryTags" aria-label="Tags" />
|
|
10
|
+
* <TagInput v-model="axes" :suggestions="known" :allow-new="false" :normalize="(t) => t.toLowerCase()" />
|
|
11
|
+
*
|
|
12
|
+
* APG combobox with a list: the arrows move through the suggestions, Enter
|
|
13
|
+
* takes the lit one (or the typed text), Escape closes the list. A tag that
|
|
14
|
+
* is already there isn't added twice; a polite live line says what
|
|
15
|
+
* happened.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, nextTick, ref, useId } from "vue";
|
|
18
|
+
import Popover from "./Popover.vue";
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
modelValue: string[];
|
|
22
|
+
suggestions?: string[] | undefined;
|
|
23
|
+
/** Take words that aren't in `suggestions`. */
|
|
24
|
+
allowNew?: boolean | undefined;
|
|
25
|
+
/** At most this many tags. */
|
|
26
|
+
max?: number | undefined;
|
|
27
|
+
/** Clean a tag before it is added (trim by default). Return "" to refuse it. */
|
|
28
|
+
normalize?: ((t: string) => string) | undefined;
|
|
29
|
+
placeholder?: string | undefined;
|
|
30
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
31
|
+
disabled?: boolean | undefined;
|
|
32
|
+
invalid?: boolean | undefined;
|
|
33
|
+
ariaLabel?: string | undefined;
|
|
34
|
+
/** How many suggestions to show at once. */
|
|
35
|
+
limit?: number | undefined;
|
|
36
|
+
}>(), {
|
|
37
|
+
suggestions: () => [], allowNew: true, max: undefined,
|
|
38
|
+
placeholder: "Add a tag", size: "md", ariaLabel: "Tags", limit: 8,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: string[]): void }>();
|
|
42
|
+
|
|
43
|
+
const uid = useId();
|
|
44
|
+
const listId = `wp-tag-input-list-${uid}`;
|
|
45
|
+
const inputEl = ref<HTMLInputElement | null>(null);
|
|
46
|
+
const query = ref("");
|
|
47
|
+
/** The list may show: the box has focus and nobody closed the list since the last key. */
|
|
48
|
+
const wanted = ref(false);
|
|
49
|
+
const active = ref(-1);
|
|
50
|
+
const said = ref("");
|
|
51
|
+
|
|
52
|
+
const clean = (t: string): string => (props.normalize ? props.normalize(t) : t.trim());
|
|
53
|
+
const has = (t: string): boolean => props.modelValue.some((x) => x.toLowerCase() === t.toLowerCase());
|
|
54
|
+
const full = computed(() => props.max !== undefined && props.modelValue.length >= props.max);
|
|
55
|
+
|
|
56
|
+
const matches = computed(() => {
|
|
57
|
+
const q = query.value.trim().toLowerCase();
|
|
58
|
+
const pool = props.suggestions.filter((s) => !has(s));
|
|
59
|
+
const hit = q ? pool.filter((s) => s.toLowerCase().includes(q)) : pool;
|
|
60
|
+
// Words that start with the query first, then the rest.
|
|
61
|
+
return [...hit.filter((s) => s.toLowerCase().startsWith(q)), ...hit.filter((s) => !s.toLowerCase().startsWith(q))].slice(0, props.limit);
|
|
62
|
+
});
|
|
63
|
+
const open = computed(() => wanted.value && !full.value && !props.disabled && matches.value.length > 0);
|
|
64
|
+
const activeId = computed(() => (open.value && active.value >= 0 ? `${listId}-${active.value}` : undefined));
|
|
65
|
+
|
|
66
|
+
function announce(text: string): void {
|
|
67
|
+
said.value = "";
|
|
68
|
+
void nextTick(() => { said.value = text; });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function add(raw: string): boolean {
|
|
72
|
+
const t = clean(raw);
|
|
73
|
+
if (!t) return false;
|
|
74
|
+
if (has(t)) { announce(`${t} is already added`); return false; }
|
|
75
|
+
if (full.value) { announce(`At most ${props.max} tags`); return false; }
|
|
76
|
+
const known = props.suggestions.find((s) => s.toLowerCase() === t.toLowerCase());
|
|
77
|
+
if (!props.allowNew && !known) { announce(`${t} is not one of the choices`); return false; }
|
|
78
|
+
emit("update:modelValue", [...props.modelValue, known ?? t]);
|
|
79
|
+
announce(`Added ${known ?? t}`);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function addMany(parts: string[]): void {
|
|
84
|
+
let list = [...props.modelValue];
|
|
85
|
+
const added: string[] = [];
|
|
86
|
+
for (const p of parts) {
|
|
87
|
+
const t = clean(p);
|
|
88
|
+
if (!t || list.some((x) => x.toLowerCase() === t.toLowerCase())) continue;
|
|
89
|
+
if (props.max !== undefined && list.length >= props.max) break;
|
|
90
|
+
const known = props.suggestions.find((s) => s.toLowerCase() === t.toLowerCase());
|
|
91
|
+
if (!props.allowNew && !known) continue;
|
|
92
|
+
list = [...list, known ?? t];
|
|
93
|
+
added.push(known ?? t);
|
|
94
|
+
}
|
|
95
|
+
if (added.length) {
|
|
96
|
+
emit("update:modelValue", list);
|
|
97
|
+
announce(`Added ${added.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function remove(i: number, refocus = false): void {
|
|
102
|
+
const t = props.modelValue[i];
|
|
103
|
+
if (t === undefined || props.disabled) return;
|
|
104
|
+
emit("update:modelValue", props.modelValue.filter((_, j) => j !== i));
|
|
105
|
+
announce(`Removed ${t}`);
|
|
106
|
+
if (refocus) inputEl.value?.focus();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commit(): void {
|
|
110
|
+
if (open.value && active.value >= 0 && matches.value[active.value]) {
|
|
111
|
+
if (add(matches.value[active.value])) query.value = "";
|
|
112
|
+
} else if (query.value.trim() && add(query.value)) {
|
|
113
|
+
query.value = "";
|
|
114
|
+
}
|
|
115
|
+
active.value = -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
119
|
+
if (props.disabled) return;
|
|
120
|
+
switch (ev.key) {
|
|
121
|
+
case "Enter":
|
|
122
|
+
if (!query.value.trim() && active.value < 0) return;
|
|
123
|
+
ev.preventDefault();
|
|
124
|
+
commit();
|
|
125
|
+
return;
|
|
126
|
+
case ",":
|
|
127
|
+
ev.preventDefault();
|
|
128
|
+
if (query.value.trim() && add(query.value)) query.value = "";
|
|
129
|
+
return;
|
|
130
|
+
case "ArrowDown":
|
|
131
|
+
if (!matches.value.length) return;
|
|
132
|
+
ev.preventDefault();
|
|
133
|
+
if (!wanted.value) { wanted.value = true; active.value = 0; return; }
|
|
134
|
+
active.value = (active.value + 1) % matches.value.length;
|
|
135
|
+
return;
|
|
136
|
+
case "ArrowUp":
|
|
137
|
+
if (!matches.value.length) return;
|
|
138
|
+
ev.preventDefault();
|
|
139
|
+
active.value = active.value <= 0 ? matches.value.length - 1 : active.value - 1;
|
|
140
|
+
return;
|
|
141
|
+
case "Backspace":
|
|
142
|
+
if (query.value === "" && props.modelValue.length) {
|
|
143
|
+
ev.preventDefault();
|
|
144
|
+
remove(props.modelValue.length - 1);
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
default:
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onInput(): void {
|
|
152
|
+
wanted.value = true;
|
|
153
|
+
if (query.value.includes(",")) {
|
|
154
|
+
const parts = query.value.split(",");
|
|
155
|
+
query.value = parts.pop() ?? "";
|
|
156
|
+
addMany(parts);
|
|
157
|
+
}
|
|
158
|
+
active.value = -1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onPaste(ev: ClipboardEvent): void {
|
|
162
|
+
const text = ev.clipboardData?.getData("text") ?? "";
|
|
163
|
+
if (!/[,\n]/.test(text)) return;
|
|
164
|
+
ev.preventDefault();
|
|
165
|
+
addMany(text.split(/[,\n]/));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function pick(s: string): void {
|
|
169
|
+
if (add(s)) query.value = "";
|
|
170
|
+
active.value = -1;
|
|
171
|
+
inputEl.value?.focus();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setOpen(v: boolean): void {
|
|
175
|
+
// The list closes itself on Escape or a click outside; reopen on typing.
|
|
176
|
+
if (!v) { wanted.value = false; active.value = -1; }
|
|
177
|
+
}
|
|
178
|
+
</script>
|
|
179
|
+
|
|
180
|
+
<!-- Popover's autofocus prop is off here: focus stays in the box you type in. -->
|
|
181
|
+
<!-- eslint-disable vuejs-accessibility/no-autofocus -->
|
|
182
|
+
<template>
|
|
183
|
+
<Popover :open="open" role="group" match-width :autofocus="false" :label="`${ariaLabel} suggestions`" @update:open="setOpen">
|
|
184
|
+
<template #trigger>
|
|
185
|
+
<!-- A click on the box's padding focuses the text field, which has the keys. -->
|
|
186
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
187
|
+
<div
|
|
188
|
+
class="wp-tag-input wp-field-box"
|
|
189
|
+
:class="`wp-field-box--${size}`"
|
|
190
|
+
:data-size="size"
|
|
191
|
+
:data-disabled="disabled || undefined"
|
|
192
|
+
:data-invalid="invalid || undefined"
|
|
193
|
+
@click="inputEl?.focus()"
|
|
194
|
+
>
|
|
195
|
+
<span v-for="(t, i) in modelValue" :key="t" class="wp-tag-input__chip">
|
|
196
|
+
<span class="wp-tag-input__chip-text">{{ t }}</span>
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
class="wp-tag-input__remove"
|
|
200
|
+
:aria-label="`Remove ${t}`"
|
|
201
|
+
:disabled="disabled"
|
|
202
|
+
@click.stop="remove(i, true)"
|
|
203
|
+
><i class="pi pi-times" aria-hidden="true" /></button>
|
|
204
|
+
</span>
|
|
205
|
+
<input
|
|
206
|
+
ref="inputEl"
|
|
207
|
+
v-model="query"
|
|
208
|
+
class="wp-tag-input__field"
|
|
209
|
+
type="text"
|
|
210
|
+
role="combobox"
|
|
211
|
+
aria-autocomplete="list"
|
|
212
|
+
:aria-label="ariaLabel"
|
|
213
|
+
:aria-expanded="open"
|
|
214
|
+
:aria-controls="open ? listId : undefined"
|
|
215
|
+
:aria-activedescendant="activeId"
|
|
216
|
+
:aria-invalid="invalid || undefined"
|
|
217
|
+
:placeholder="full ? '' : placeholder"
|
|
218
|
+
:disabled="disabled || full"
|
|
219
|
+
autocomplete="off"
|
|
220
|
+
@focus="wanted = true"
|
|
221
|
+
@blur="wanted = false"
|
|
222
|
+
@keydown="onKeydown"
|
|
223
|
+
@input="onInput"
|
|
224
|
+
@paste="onPaste"
|
|
225
|
+
>
|
|
226
|
+
<span class="wp-tag-input__sr" aria-live="polite">{{ said }}</span>
|
|
227
|
+
</div>
|
|
228
|
+
</template>
|
|
229
|
+
<ul :id="listId" class="wp-tag-input__list" role="listbox" :aria-label="`${ariaLabel} suggestions`">
|
|
230
|
+
<!-- Options are lit with aria-activedescendant; the text field keeps focus and the keys. -->
|
|
231
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/interactive-supports-focus -->
|
|
232
|
+
<li
|
|
233
|
+
v-for="(s, i) in matches"
|
|
234
|
+
:id="`${listId}-${i}`"
|
|
235
|
+
:key="s"
|
|
236
|
+
class="wp-tag-input__opt"
|
|
237
|
+
role="option"
|
|
238
|
+
:aria-selected="i === active"
|
|
239
|
+
:data-active="i === active || undefined"
|
|
240
|
+
@mousedown.prevent
|
|
241
|
+
@click="pick(s)"
|
|
242
|
+
>{{ s }}</li>
|
|
243
|
+
</ul>
|
|
244
|
+
</Popover>
|
|
245
|
+
</template>
|
|
246
|
+
|
|
247
|
+
<style scoped src="./field.css"></style>
|
|
248
|
+
|
|
249
|
+
<style scoped>
|
|
250
|
+
.wp-tag-input {
|
|
251
|
+
--_h: var(--wp-field-h);
|
|
252
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
|
|
253
|
+
width: 100%; padding: 3px var(--wp-space-3);
|
|
254
|
+
cursor: text;
|
|
255
|
+
font-family: var(--wp-font-sans); font-weight: 400; line-height: 1;
|
|
256
|
+
}
|
|
257
|
+
.wp-tag-input[data-size="sm"] { padding: 2px var(--wp-space-2); gap: 3px; }
|
|
258
|
+
.wp-tag-input[data-size="lg"] { padding: 5px var(--wp-space-4); }
|
|
259
|
+
.wp-tag-input__chip {
|
|
260
|
+
display: inline-flex; align-items: center; gap: 2px; max-width: 100%; box-sizing: border-box;
|
|
261
|
+
height: 20px; padding: 0 2px 0 7px;
|
|
262
|
+
border: 1px solid var(--wp-border-strong); border-radius: var(--wp-radius-cap, 999px); background: var(--wp-bg-3);
|
|
263
|
+
color: var(--wp-text); font-size: var(--wp-text-xs); line-height: 1;
|
|
264
|
+
}
|
|
265
|
+
.wp-tag-input[data-size="sm"] .wp-tag-input__chip { height: 16px; padding-left: 5px; font-size: 10.5px; }
|
|
266
|
+
.wp-tag-input__chip-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
267
|
+
/* Plain ✕: no background zone, the chip keeps its height; the hit area is 20 px. */
|
|
268
|
+
.wp-tag-input__remove {
|
|
269
|
+
display: inline-grid; place-items: center; width: 16px; height: 16px; margin: -2px 0; padding: 0;
|
|
270
|
+
border: 0; border-radius: min(50%, var(--wp-radius-cap, 999px)); background: transparent; color: var(--wp-text-muted); cursor: pointer;
|
|
271
|
+
position: relative;
|
|
272
|
+
}
|
|
273
|
+
.wp-tag-input__remove::before { content: ""; position: absolute; inset: -2px; }
|
|
274
|
+
.wp-tag-input__remove .pi { font-size: 8px; }
|
|
275
|
+
.wp-tag-input__remove:hover:not(:disabled) { color: var(--wp-text); }
|
|
276
|
+
.wp-tag-input__remove:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
277
|
+
.wp-tag-input__field {
|
|
278
|
+
flex: 1 0 6ch; min-width: 6ch; height: calc(var(--_h) - 8px); margin: 0; padding: 0 2px;
|
|
279
|
+
border: 0; outline: none; background: transparent; color: inherit; font: inherit;
|
|
280
|
+
}
|
|
281
|
+
.wp-tag-input__field::placeholder { color: var(--wp-text-dim); }
|
|
282
|
+
.wp-tag-input__field:disabled { cursor: not-allowed; }
|
|
283
|
+
.wp-tag-input__sr {
|
|
284
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
285
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
286
|
+
}
|
|
287
|
+
.wp-tag-input__list { margin: calc(var(--wp-space-2) * -1); padding: 0; list-style: none; }
|
|
288
|
+
.wp-tag-input__opt {
|
|
289
|
+
padding: 6px var(--wp-space-4); border-radius: var(--wp-radius-sm);
|
|
290
|
+
color: var(--wp-text); font-size: var(--wp-text-sm); cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
.wp-tag-input__opt:hover, .wp-tag-input__opt[data-active] { background: var(--wp-row-hover); }
|
|
293
|
+
.wp-tag-input__opt[data-active] { box-shadow: inset 2px 0 0 var(--wp-accent); }
|
|
294
|
+
@media (prefers-reduced-motion: reduce) {
|
|
295
|
+
.wp-tag-input { transition: none; }
|
|
296
|
+
}
|
|
297
|
+
</style>
|