@dumiflex/wp-kit 0.0.0-stage → 0.4.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 +77 -0
- package/LICENSE +674 -0
- package/README.md +72 -2
- package/package.json +76 -4
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +109 -0
- package/src/Chip.vue +163 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +188 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +331 -0
- package/src/ConfirmDialog.vue +165 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +386 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +175 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +78 -0
- package/src/ModalShell.vue +109 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +180 -0
- package/src/PostCard.vue +210 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +192 -0
- package/src/Select.vue +644 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +297 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +104 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +178 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +93 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/field.css +50 -0
- package/src/flip.ts +237 -0
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2896 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
package/src/Combobox.vue
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string | number">
|
|
2
|
+
/**
|
|
3
|
+
* A select you can type in: the list filters as you type, the arrows light
|
|
4
|
+
* an option and Enter takes it. With `multiple` the picks sit as chips in
|
|
5
|
+
* the box, the list stays open and marks what is picked, and Backspace in
|
|
6
|
+
* the empty box takes the last pick off. The list floats in a kit Popover,
|
|
7
|
+
* so it follows the box on scroll. Use WpSelect for a short fixed list and
|
|
8
|
+
* TagInput when people may type their own words.
|
|
9
|
+
*
|
|
10
|
+
* <Combobox v-model="model" :options="models" aria-label="Model" />
|
|
11
|
+
* <Combobox v-model="cats" multiple :options="categories" aria-label="Categories" />
|
|
12
|
+
*
|
|
13
|
+
* APG "combobox with list autocomplete": focus stays in the text field and
|
|
14
|
+
* the lit option is named by aria-activedescendant.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, nextTick, ref, useId, watch } from "vue";
|
|
17
|
+
import Popover from "./Popover.vue";
|
|
18
|
+
|
|
19
|
+
export interface ComboOption<V> { value: V; label: string; hint?: string; disabled?: boolean }
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(defineProps<{
|
|
22
|
+
modelValue: T | null | T[];
|
|
23
|
+
options: ComboOption<T>[];
|
|
24
|
+
multiple?: boolean | undefined;
|
|
25
|
+
placeholder?: string | undefined;
|
|
26
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
27
|
+
disabled?: boolean | undefined;
|
|
28
|
+
invalid?: boolean | undefined;
|
|
29
|
+
ariaLabel?: string | undefined;
|
|
30
|
+
/** Show a ✕ that clears a single pick. */
|
|
31
|
+
clearable?: boolean | undefined;
|
|
32
|
+
/** What the list says when nothing matches. */
|
|
33
|
+
emptyText?: string | undefined;
|
|
34
|
+
/** How many options the list shows at once (it scrolls past that). */
|
|
35
|
+
limit?: number | undefined;
|
|
36
|
+
}>(), {
|
|
37
|
+
multiple: false, placeholder: "Choose…", size: "md", ariaLabel: "Choose",
|
|
38
|
+
clearable: false, emptyText: "No matches", limit: 50,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: T | null | T[]): void }>();
|
|
42
|
+
|
|
43
|
+
const uid = useId();
|
|
44
|
+
const listId = `wp-combo-list-${uid}`;
|
|
45
|
+
const inputEl = ref<HTMLInputElement | null>(null);
|
|
46
|
+
const query = ref("");
|
|
47
|
+
const typing = ref(false);
|
|
48
|
+
const wanted = ref(false);
|
|
49
|
+
const active = ref(-1);
|
|
50
|
+
const said = ref("");
|
|
51
|
+
|
|
52
|
+
const picks = computed<T[]>(() => {
|
|
53
|
+
const v = props.modelValue;
|
|
54
|
+
if (Array.isArray(v)) return v;
|
|
55
|
+
return v === null || v === undefined ? [] : [v];
|
|
56
|
+
});
|
|
57
|
+
const isPicked = (v: T): boolean => picks.value.includes(v);
|
|
58
|
+
const labelOf = (v: T): string => props.options.find((o) => o.value === v)?.label ?? String(v);
|
|
59
|
+
const single = computed(() => (!props.multiple && picks.value.length ? labelOf(picks.value[0]) : ""));
|
|
60
|
+
|
|
61
|
+
const matches = computed(() => {
|
|
62
|
+
const q = typing.value ? query.value.trim().toLowerCase() : "";
|
|
63
|
+
const hit = q ? props.options.filter((o) => o.label.toLowerCase().includes(q)) : props.options;
|
|
64
|
+
return [...hit.filter((o) => o.label.toLowerCase().startsWith(q)), ...hit.filter((o) => !o.label.toLowerCase().startsWith(q))]
|
|
65
|
+
.slice(0, props.limit);
|
|
66
|
+
});
|
|
67
|
+
const open = computed(() => wanted.value && !props.disabled);
|
|
68
|
+
const activeId = computed(() => (open.value && active.value >= 0 ? `${listId}-${active.value}` : undefined));
|
|
69
|
+
|
|
70
|
+
// The text field shows the single pick's label while you are not typing.
|
|
71
|
+
watch(single, (l) => { if (!typing.value) query.value = l; }, { immediate: true });
|
|
72
|
+
|
|
73
|
+
function announce(text: string): void {
|
|
74
|
+
said.value = "";
|
|
75
|
+
void nextTick(() => { said.value = text; });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function choose(o: ComboOption<T>): void {
|
|
79
|
+
if (o.disabled) return;
|
|
80
|
+
if (props.multiple) {
|
|
81
|
+
const on = isPicked(o.value);
|
|
82
|
+
emit("update:modelValue", on ? picks.value.filter((v) => v !== o.value) : [...picks.value, o.value]);
|
|
83
|
+
announce(`${on ? "Removed" : "Added"} ${o.label}`);
|
|
84
|
+
query.value = "";
|
|
85
|
+
typing.value = false;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
emit("update:modelValue", o.value);
|
|
89
|
+
typing.value = false;
|
|
90
|
+
query.value = o.label;
|
|
91
|
+
wanted.value = false;
|
|
92
|
+
active.value = -1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function removeAt(i: number, refocus = false): void {
|
|
96
|
+
const v = picks.value[i];
|
|
97
|
+
if (v === undefined || props.disabled) return;
|
|
98
|
+
emit("update:modelValue", picks.value.filter((_, j) => j !== i));
|
|
99
|
+
announce(`Removed ${labelOf(v)}`);
|
|
100
|
+
if (refocus) inputEl.value?.focus();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function clear(): void {
|
|
104
|
+
emit("update:modelValue", props.multiple ? [] : null);
|
|
105
|
+
query.value = "";
|
|
106
|
+
typing.value = false;
|
|
107
|
+
announce("Cleared");
|
|
108
|
+
inputEl.value?.focus();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function step(dir: 1 | -1): void {
|
|
112
|
+
const n = matches.value.length;
|
|
113
|
+
if (!n) return;
|
|
114
|
+
let i = active.value;
|
|
115
|
+
for (let k = 0; k < n; k++) {
|
|
116
|
+
i = i < 0 ? (dir > 0 ? 0 : n - 1) : (i + dir + n) % n;
|
|
117
|
+
if (!matches.value[i].disabled) break;
|
|
118
|
+
}
|
|
119
|
+
active.value = i;
|
|
120
|
+
void nextTick(() => document.getElementById(`${listId}-${i}`)?.scrollIntoView?.({ block: "nearest" }));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
124
|
+
if (props.disabled) return;
|
|
125
|
+
switch (ev.key) {
|
|
126
|
+
case "ArrowDown":
|
|
127
|
+
case "ArrowUp":
|
|
128
|
+
ev.preventDefault();
|
|
129
|
+
if (!wanted.value) { wanted.value = true; if (ev.altKey) return; }
|
|
130
|
+
step(ev.key === "ArrowDown" ? 1 : -1);
|
|
131
|
+
return;
|
|
132
|
+
case "Enter": {
|
|
133
|
+
const o = open.value && active.value >= 0 ? matches.value[active.value] : undefined;
|
|
134
|
+
if (!o) return;
|
|
135
|
+
ev.preventDefault();
|
|
136
|
+
choose(o);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
case "Escape":
|
|
140
|
+
if (typing.value && !props.multiple) { typing.value = false; query.value = single.value; }
|
|
141
|
+
return;
|
|
142
|
+
case "Backspace":
|
|
143
|
+
if (props.multiple && query.value === "" && picks.value.length) {
|
|
144
|
+
ev.preventDefault();
|
|
145
|
+
removeAt(picks.value.length - 1);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
default:
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onInput(): void {
|
|
153
|
+
typing.value = true;
|
|
154
|
+
wanted.value = true;
|
|
155
|
+
active.value = matches.value.findIndex((o) => !o.disabled);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function onBlur(): void {
|
|
159
|
+
wanted.value = false;
|
|
160
|
+
active.value = -1;
|
|
161
|
+
if (!props.multiple) { typing.value = false; query.value = single.value; }
|
|
162
|
+
else query.value = "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pick(o: ComboOption<T>): void {
|
|
166
|
+
choose(o);
|
|
167
|
+
inputEl.value?.focus();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function setOpen(v: boolean): void {
|
|
171
|
+
if (!v) { wanted.value = false; active.value = -1; }
|
|
172
|
+
}
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<!-- Popover's autofocus prop is off here: focus stays in the box you type in. -->
|
|
176
|
+
<!-- eslint-disable vuejs-accessibility/no-autofocus -->
|
|
177
|
+
<template>
|
|
178
|
+
<Popover :open="open" role="group" match-width :autofocus="false" :label="`${ariaLabel} choices`" @update:open="setOpen">
|
|
179
|
+
<template #trigger>
|
|
180
|
+
<!-- A click on the box's padding focuses the text field, which has the keys. -->
|
|
181
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
182
|
+
<div
|
|
183
|
+
class="wp-combo wp-field-box"
|
|
184
|
+
:class="`wp-field-box--${size}`"
|
|
185
|
+
:data-size="size"
|
|
186
|
+
:data-multiple="multiple || undefined"
|
|
187
|
+
:data-disabled="disabled || undefined"
|
|
188
|
+
:data-invalid="invalid || undefined"
|
|
189
|
+
@click="inputEl?.focus(); wanted = true"
|
|
190
|
+
>
|
|
191
|
+
<template v-if="multiple">
|
|
192
|
+
<span v-for="(v, i) in picks" :key="String(v)" class="wp-combo__chip">
|
|
193
|
+
<span class="wp-combo__chip-text">{{ labelOf(v) }}</span>
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
class="wp-combo__remove"
|
|
197
|
+
:aria-label="`Remove ${labelOf(v)}`"
|
|
198
|
+
:disabled="disabled"
|
|
199
|
+
@click.stop="removeAt(i, true)"
|
|
200
|
+
><i class="pi pi-times" aria-hidden="true" /></button>
|
|
201
|
+
</span>
|
|
202
|
+
</template>
|
|
203
|
+
<input
|
|
204
|
+
ref="inputEl"
|
|
205
|
+
v-model="query"
|
|
206
|
+
class="wp-combo__field"
|
|
207
|
+
type="text"
|
|
208
|
+
role="combobox"
|
|
209
|
+
aria-autocomplete="list"
|
|
210
|
+
:aria-label="ariaLabel"
|
|
211
|
+
:aria-expanded="open"
|
|
212
|
+
:aria-controls="open ? listId : undefined"
|
|
213
|
+
:aria-activedescendant="activeId"
|
|
214
|
+
:aria-invalid="invalid || undefined"
|
|
215
|
+
:placeholder="multiple && picks.length ? '' : placeholder"
|
|
216
|
+
:disabled="disabled"
|
|
217
|
+
autocomplete="off"
|
|
218
|
+
@focus="wanted = true"
|
|
219
|
+
@blur="onBlur"
|
|
220
|
+
@keydown="onKeydown"
|
|
221
|
+
@input="onInput"
|
|
222
|
+
>
|
|
223
|
+
<button
|
|
224
|
+
v-if="clearable && picks.length && !disabled"
|
|
225
|
+
type="button"
|
|
226
|
+
class="wp-combo__clear"
|
|
227
|
+
aria-label="Clear"
|
|
228
|
+
@mousedown.prevent
|
|
229
|
+
@click.stop="clear"
|
|
230
|
+
><i class="pi pi-times" aria-hidden="true" /></button>
|
|
231
|
+
<i class="pi pi-chevron-down wp-combo__caret" aria-hidden="true" />
|
|
232
|
+
<span class="wp-combo__sr" aria-live="polite">{{ said }}</span>
|
|
233
|
+
</div>
|
|
234
|
+
</template>
|
|
235
|
+
<ul
|
|
236
|
+
:id="listId"
|
|
237
|
+
class="wp-combo__list"
|
|
238
|
+
role="listbox"
|
|
239
|
+
:aria-label="`${ariaLabel} choices`"
|
|
240
|
+
:aria-multiselectable="multiple || undefined"
|
|
241
|
+
>
|
|
242
|
+
<!-- Options are lit with aria-activedescendant; the text field keeps focus and the keys. -->
|
|
243
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/interactive-supports-focus -->
|
|
244
|
+
<li
|
|
245
|
+
v-for="(o, i) in matches"
|
|
246
|
+
:id="`${listId}-${i}`"
|
|
247
|
+
:key="String(o.value)"
|
|
248
|
+
class="wp-combo__opt"
|
|
249
|
+
role="option"
|
|
250
|
+
:aria-selected="isPicked(o.value)"
|
|
251
|
+
:aria-disabled="o.disabled || undefined"
|
|
252
|
+
:data-active="i === active || undefined"
|
|
253
|
+
@mousedown.prevent
|
|
254
|
+
@click="pick(o)"
|
|
255
|
+
>
|
|
256
|
+
<i class="pi pi-check wp-combo__tick" :data-on="isPicked(o.value) || undefined" aria-hidden="true" />
|
|
257
|
+
<span class="wp-combo__opt-text">
|
|
258
|
+
<span>{{ o.label }}</span>
|
|
259
|
+
<span v-if="o.hint" class="wp-combo__hint">{{ o.hint }}</span>
|
|
260
|
+
</span>
|
|
261
|
+
</li>
|
|
262
|
+
<li v-if="!matches.length" class="wp-combo__empty" role="presentation">{{ emptyText }}</li>
|
|
263
|
+
</ul>
|
|
264
|
+
</Popover>
|
|
265
|
+
</template>
|
|
266
|
+
|
|
267
|
+
<style scoped src="./field.css"></style>
|
|
268
|
+
|
|
269
|
+
<style scoped>
|
|
270
|
+
.wp-combo {
|
|
271
|
+
--_h: var(--wp-field-h);
|
|
272
|
+
position: relative;
|
|
273
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
|
|
274
|
+
width: 100%; padding: 3px 28px 3px var(--wp-space-3);
|
|
275
|
+
cursor: text;
|
|
276
|
+
font-family: var(--wp-font-sans); font-weight: 400; line-height: 1;
|
|
277
|
+
}
|
|
278
|
+
.wp-combo[data-size="sm"] { padding: 2px 24px 2px var(--wp-space-2); gap: 3px; }
|
|
279
|
+
.wp-combo[data-size="lg"] { padding: 5px 32px 5px var(--wp-space-4); }
|
|
280
|
+
.wp-combo__chip {
|
|
281
|
+
display: inline-flex; align-items: center; gap: 2px; max-width: 100%; box-sizing: border-box;
|
|
282
|
+
height: 20px; padding: 0 2px 0 7px;
|
|
283
|
+
border: 1px solid var(--wp-border-strong); border-radius: var(--wp-radius-cap, 999px); background: var(--wp-bg-3);
|
|
284
|
+
color: var(--wp-text); font-size: var(--wp-text-xs); line-height: 1;
|
|
285
|
+
}
|
|
286
|
+
.wp-combo[data-size="sm"] .wp-combo__chip { height: 16px; padding-left: 5px; font-size: 10.5px; }
|
|
287
|
+
.wp-combo__chip-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
288
|
+
.wp-combo__remove, .wp-combo__clear {
|
|
289
|
+
display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0;
|
|
290
|
+
border: 0; border-radius: min(50%, var(--wp-radius-cap, 999px)); background: transparent; color: var(--wp-text-muted); cursor: pointer;
|
|
291
|
+
}
|
|
292
|
+
.wp-combo__remove { margin: -2px 0; position: relative; }
|
|
293
|
+
.wp-combo__remove::before { content: ""; position: absolute; inset: -2px; }
|
|
294
|
+
.wp-combo__remove .pi, .wp-combo__clear .pi { font-size: 8px; }
|
|
295
|
+
.wp-combo__clear { position: absolute; right: 24px; top: 50%; transform: translateY(-50%); }
|
|
296
|
+
.wp-combo__remove:hover:not(:disabled), .wp-combo__clear:hover { color: var(--wp-text); }
|
|
297
|
+
.wp-combo__remove:focus-visible, .wp-combo__clear:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
298
|
+
.wp-combo__caret {
|
|
299
|
+
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
|
|
300
|
+
color: var(--wp-text-muted); font-size: 10px; pointer-events: none;
|
|
301
|
+
}
|
|
302
|
+
.wp-combo[data-size="sm"] .wp-combo__caret { right: 8px; font-size: 9px; }
|
|
303
|
+
.wp-combo__field {
|
|
304
|
+
flex: 1 0 6ch; min-width: 6ch; height: calc(var(--_h) - 8px); margin: 0; padding: 0 2px;
|
|
305
|
+
border: 0; outline: none; background: transparent; color: inherit; font: inherit;
|
|
306
|
+
text-overflow: ellipsis;
|
|
307
|
+
}
|
|
308
|
+
.wp-combo__field::placeholder { color: var(--wp-text-dim); }
|
|
309
|
+
.wp-combo__field:disabled { cursor: not-allowed; }
|
|
310
|
+
.wp-combo__sr {
|
|
311
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
312
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
313
|
+
}
|
|
314
|
+
.wp-combo__list { margin: calc(var(--wp-space-2) * -1); padding: 0; list-style: none; max-height: 280px; overflow: auto; }
|
|
315
|
+
.wp-combo__opt {
|
|
316
|
+
display: flex; align-items: flex-start; gap: var(--wp-space-2);
|
|
317
|
+
padding: 6px var(--wp-space-4) 6px var(--wp-space-2); border-radius: var(--wp-radius-sm);
|
|
318
|
+
color: var(--wp-text); font-size: var(--wp-text-sm); cursor: pointer;
|
|
319
|
+
}
|
|
320
|
+
.wp-combo__opt:hover, .wp-combo__opt[data-active] { background: var(--wp-row-hover); }
|
|
321
|
+
.wp-combo__opt[data-active] { box-shadow: inset 2px 0 0 var(--wp-accent); }
|
|
322
|
+
.wp-combo__opt[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
323
|
+
.wp-combo__tick { width: 14px; margin-top: 2px; font-size: 10px; color: var(--wp-accent); visibility: hidden; }
|
|
324
|
+
.wp-combo__tick[data-on] { visibility: visible; }
|
|
325
|
+
.wp-combo__opt-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
326
|
+
.wp-combo__hint { color: var(--wp-text-muted); font-size: var(--wp-text-xs); }
|
|
327
|
+
.wp-combo__empty { padding: 6px var(--wp-space-4); color: var(--wp-text-muted); font-size: var(--wp-text-sm); }
|
|
328
|
+
@media (prefers-reduced-motion: reduce) {
|
|
329
|
+
.wp-combo { transition: none; }
|
|
330
|
+
}
|
|
331
|
+
</style>
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Themed confirmation dialog — replaces `window.confirm()` for any
|
|
4
|
+
* action that needs an in-app prompt (reset overrides, save-to-library,
|
|
5
|
+
* reset-from-library, etc.). Consumers control it via v-model:visible
|
|
6
|
+
* plus title/body/confirm-label slots; the dialog emits `confirm` /
|
|
7
|
+
* `cancel` and closes itself on Esc + overlay click + Cancel button.
|
|
8
|
+
*
|
|
9
|
+
* Variants:
|
|
10
|
+
* - default: accent-coloured Confirm button (constructive actions)
|
|
11
|
+
* - danger: red Confirm button (destructive actions, e.g. discard,
|
|
12
|
+
* reset overrides)
|
|
13
|
+
*/
|
|
14
|
+
import { onBeforeUnmount, watch } from "vue";
|
|
15
|
+
import WpButton from "./WpButton.vue";
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(
|
|
18
|
+
defineProps<{
|
|
19
|
+
visible: boolean;
|
|
20
|
+
title: string;
|
|
21
|
+
body?: string | undefined;
|
|
22
|
+
confirmLabel?: string | undefined;
|
|
23
|
+
cancelLabel?: string | undefined;
|
|
24
|
+
variant?: "default" | "danger" | undefined;
|
|
25
|
+
}>(),
|
|
26
|
+
{
|
|
27
|
+
body: "",
|
|
28
|
+
confirmLabel: "Confirm",
|
|
29
|
+
cancelLabel: "Cancel",
|
|
30
|
+
variant: "default",
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
"confirm": [];
|
|
36
|
+
"cancel": [];
|
|
37
|
+
}>();
|
|
38
|
+
|
|
39
|
+
function onConfirm(): void { emit("confirm"); }
|
|
40
|
+
function onCancel(): void { emit("cancel"); }
|
|
41
|
+
|
|
42
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
43
|
+
if (!props.visible) return;
|
|
44
|
+
if (ev.key === "Escape") {
|
|
45
|
+
ev.preventDefault();
|
|
46
|
+
emit("cancel");
|
|
47
|
+
} else if (ev.key === "Enter" && !ev.shiftKey) {
|
|
48
|
+
ev.preventDefault();
|
|
49
|
+
emit("confirm");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
watch(
|
|
54
|
+
() => props.visible,
|
|
55
|
+
(visible) => {
|
|
56
|
+
if (visible) window.addEventListener("keydown", onKeydown);
|
|
57
|
+
else window.removeEventListener("keydown", onKeydown);
|
|
58
|
+
},
|
|
59
|
+
{ immediate: true },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<Teleport to="body">
|
|
67
|
+
<Transition name="wp-modal" appear>
|
|
68
|
+
<!-- The backdrop is a click target only; keyboard users close with Escape. -->
|
|
69
|
+
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
70
|
+
<div
|
|
71
|
+
v-if="visible"
|
|
72
|
+
class="wp-confirm-overlay"
|
|
73
|
+
data-test="confirm-overlay"
|
|
74
|
+
@click="onCancel"
|
|
75
|
+
>
|
|
76
|
+
<div
|
|
77
|
+
class="wp-confirm"
|
|
78
|
+
role="alertdialog"
|
|
79
|
+
aria-modal="true"
|
|
80
|
+
:class="`wp-confirm--${variant}`"
|
|
81
|
+
@click.stop
|
|
82
|
+
>
|
|
83
|
+
<div class="wp-confirm__head">
|
|
84
|
+
<i
|
|
85
|
+
class="pi"
|
|
86
|
+
:class="variant === 'danger' ? 'pi-exclamation-triangle' : 'pi-info-circle'"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
/>
|
|
89
|
+
<h3 class="wp-confirm__title" data-test="confirm-title">{{ title }}</h3>
|
|
90
|
+
</div>
|
|
91
|
+
<p v-if="body" class="wp-confirm__body" data-test="confirm-body">{{ body }}</p>
|
|
92
|
+
<div class="wp-confirm__foot">
|
|
93
|
+
<WpButton data-test="confirm-cancel" @click="onCancel">{{ cancelLabel }}</WpButton>
|
|
94
|
+
<WpButton :variant="variant === 'danger' ? 'danger' : 'primary'" data-test="confirm-confirm" @click="onConfirm">{{ confirmLabel }}</WpButton>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</Transition>
|
|
99
|
+
</Teleport>
|
|
100
|
+
</template>
|
|
101
|
+
|
|
102
|
+
<style>
|
|
103
|
+
/* Shared modal open/close transition — overlay fade + panel scale.
|
|
104
|
+
* See `_modal-motion.css` for rules. Imported unscoped so the
|
|
105
|
+
* `wp-modal-*` transition class names match the Vue-emitted classes
|
|
106
|
+
* cleanly (named transitions don't survive Vue's `data-v-...`
|
|
107
|
+
* scoping). */
|
|
108
|
+
@import "./_modal-motion.css";
|
|
109
|
+
</style>
|
|
110
|
+
|
|
111
|
+
<style scoped>
|
|
112
|
+
.wp-confirm-overlay {
|
|
113
|
+
position: fixed;
|
|
114
|
+
inset: 0;
|
|
115
|
+
z-index: 10000;
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
padding: 20px;
|
|
120
|
+
background: var(--wp-overlay-bg, rgba(0, 0, 0, 0.62));
|
|
121
|
+
font-family: var(--wp-font-sans, sans-serif);
|
|
122
|
+
}
|
|
123
|
+
.wp-confirm {
|
|
124
|
+
background: var(--wp-bg-2);
|
|
125
|
+
border: 1px solid var(--wp-border);
|
|
126
|
+
border-radius: var(--wp-radius);
|
|
127
|
+
width: 380px;
|
|
128
|
+
max-width: 100%;
|
|
129
|
+
color: var(--wp-text);
|
|
130
|
+
box-shadow: var(--wp-shadow-lg, 0 12px 40px rgba(0, 0, 0, 0.5));
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
}
|
|
133
|
+
.wp-confirm__head {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 10px;
|
|
137
|
+
padding: 14px 16px 8px;
|
|
138
|
+
}
|
|
139
|
+
.wp-confirm__head .pi {
|
|
140
|
+
font-size: 16px;
|
|
141
|
+
color: var(--wp-accent-text, var(--wp-text));
|
|
142
|
+
}
|
|
143
|
+
.wp-confirm--danger .wp-confirm__head .pi {
|
|
144
|
+
color: var(--wp-danger, var(--wp-red));
|
|
145
|
+
}
|
|
146
|
+
.wp-confirm__title {
|
|
147
|
+
font: 600 13px var(--wp-font-sans);
|
|
148
|
+
color: var(--wp-text);
|
|
149
|
+
margin: 0;
|
|
150
|
+
}
|
|
151
|
+
.wp-confirm__body {
|
|
152
|
+
font: 12px/1.5 var(--wp-font-sans);
|
|
153
|
+
color: var(--wp-text-muted);
|
|
154
|
+
margin: 0;
|
|
155
|
+
padding: 0 16px 14px;
|
|
156
|
+
}
|
|
157
|
+
.wp-confirm__foot {
|
|
158
|
+
display: flex;
|
|
159
|
+
justify-content: flex-end;
|
|
160
|
+
gap: 8px;
|
|
161
|
+
padding: 10px 14px;
|
|
162
|
+
background: var(--wp-bg-3);
|
|
163
|
+
border-top: 1px solid var(--wp-border);
|
|
164
|
+
}
|
|
165
|
+
</style>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Ask before an action, in the manager's own dialog.
|
|
4
|
+
*
|
|
5
|
+
* A thin wrapper on Modal: the same backdrop, focus trap, Escape and focus
|
|
6
|
+
* return as every other manager dialog, with a Cancel button and a confirm
|
|
7
|
+
* button that names the action. The canvas keeps `ConfirmDialog`, which
|
|
8
|
+
* wears the node colours; this one wears the manager's.
|
|
9
|
+
*
|
|
10
|
+
* Escape, the backdrop, the ✕ and Cancel all emit `cancel`. Enter confirms
|
|
11
|
+
* unless focus is on a button or a text box (which then gets the key itself).
|
|
12
|
+
* The `confirm-*` data-test names match ConfirmDialog's, so a test can drive
|
|
13
|
+
* either.
|
|
14
|
+
*
|
|
15
|
+
* `requireType` makes the user type that exact text before Confirm works
|
|
16
|
+
* (deleting an account); `confirmDisabled` holds Confirm off for a gate the
|
|
17
|
+
* page owns (a required reason in the slot). `warn` asks about something
|
|
18
|
+
* that can be undone but matters: an outlined button and an info mark.
|
|
19
|
+
*/
|
|
20
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
21
|
+
import Modal from "./Modal.vue";
|
|
22
|
+
import Button from "./Button.vue";
|
|
23
|
+
import Icon from "./Icon.vue";
|
|
24
|
+
import Input from "./Input.vue";
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(
|
|
27
|
+
defineProps<{
|
|
28
|
+
open: boolean;
|
|
29
|
+
title: string;
|
|
30
|
+
body?: string | undefined;
|
|
31
|
+
confirmLabel?: string | undefined;
|
|
32
|
+
cancelLabel?: string | undefined;
|
|
33
|
+
/** `danger` for something that can't be undone: red button, warning
|
|
34
|
+
* mark. `warn` for something that matters but can be undone. */
|
|
35
|
+
tone?: "accent" | "warn" | "danger" | undefined;
|
|
36
|
+
loading?: boolean | undefined;
|
|
37
|
+
/** The text the user must type before Confirm works. */
|
|
38
|
+
requireType?: string | undefined;
|
|
39
|
+
/** Hold Confirm off (a required field in the slot is empty). */
|
|
40
|
+
confirmDisabled?: boolean | undefined;
|
|
41
|
+
}>(),
|
|
42
|
+
{ body: "", confirmLabel: "Confirm", cancelLabel: "Cancel", tone: "accent", loading: false, requireType: "", confirmDisabled: false },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
confirm: [];
|
|
47
|
+
cancel: [];
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
function onOpen(v: boolean): void {
|
|
51
|
+
if (!v) emit("cancel");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const typed = ref("");
|
|
55
|
+
const blocked = computed(
|
|
56
|
+
() => props.loading || props.confirmDisabled || (!!props.requireType && typed.value !== props.requireType),
|
|
57
|
+
);
|
|
58
|
+
const confirmVariant = computed(() => (props.tone === "danger" ? "danger" : props.tone === "warn" ? "outline" : "primary"));
|
|
59
|
+
|
|
60
|
+
function confirm(): void {
|
|
61
|
+
if (!blocked.value) emit("confirm");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
65
|
+
if (ev.key !== "Enter" || ev.shiftKey || blocked.value) return;
|
|
66
|
+
// A focused button (Cancel, ✕) or a text box in the slot keeps its Enter.
|
|
67
|
+
const t = ev.target;
|
|
68
|
+
if (t instanceof HTMLButtonElement || t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return;
|
|
69
|
+
ev.preventDefault();
|
|
70
|
+
emit("confirm");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
watch(
|
|
74
|
+
() => props.open,
|
|
75
|
+
(open) => {
|
|
76
|
+
if (open) {
|
|
77
|
+
typed.value = "";
|
|
78
|
+
window.addEventListener("keydown", onKeydown);
|
|
79
|
+
}
|
|
80
|
+
else window.removeEventListener("keydown", onKeydown);
|
|
81
|
+
},
|
|
82
|
+
{ immediate: true },
|
|
83
|
+
);
|
|
84
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<template>
|
|
88
|
+
<Modal :open="open" size="sm" @update:open="onOpen">
|
|
89
|
+
<template #header="{ titleId }">
|
|
90
|
+
<span class="wp-confirm-modal__head" :class="`wp-confirm-modal__head--${tone}`">
|
|
91
|
+
<Icon :name="tone === 'danger' ? 'exclamation-triangle' : 'info-circle'" />
|
|
92
|
+
<h3 :id="titleId" class="wp-modal__title" data-test="confirm-title">{{ title }}</h3>
|
|
93
|
+
</span>
|
|
94
|
+
</template>
|
|
95
|
+
<div class="wp-confirm-modal__body">
|
|
96
|
+
<p v-if="body" class="wp-confirm-modal__text" data-test="confirm-body">{{ body }}</p>
|
|
97
|
+
<slot />
|
|
98
|
+
<label v-if="requireType" class="wp-confirm-modal__gate">
|
|
99
|
+
<span>Type <code>{{ requireType }}</code> to confirm:</span>
|
|
100
|
+
<Input v-model="typed" autocomplete="off" spellcheck="false" data-test="confirm-type" />
|
|
101
|
+
</label>
|
|
102
|
+
</div>
|
|
103
|
+
<template #footer>
|
|
104
|
+
<Button variant="ghost" :disabled="loading" data-test="confirm-cancel" @click="emit('cancel')">{{ cancelLabel }}</Button>
|
|
105
|
+
<Button
|
|
106
|
+
:variant="confirmVariant"
|
|
107
|
+
:loading="loading"
|
|
108
|
+
:disabled="blocked && !loading"
|
|
109
|
+
data-test="confirm-confirm"
|
|
110
|
+
@click="confirm"
|
|
111
|
+
>{{ confirmLabel }}</Button>
|
|
112
|
+
</template>
|
|
113
|
+
</Modal>
|
|
114
|
+
</template>
|
|
115
|
+
|
|
116
|
+
<style scoped>
|
|
117
|
+
.wp-confirm-modal__head {
|
|
118
|
+
display: inline-flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: var(--wp-space-3);
|
|
121
|
+
min-width: 0;
|
|
122
|
+
color: var(--wp-accent-text);
|
|
123
|
+
}
|
|
124
|
+
.wp-confirm-modal__head--danger { color: var(--wp-danger); }
|
|
125
|
+
.wp-confirm-modal__head--warn { color: var(--wp-warn); }
|
|
126
|
+
.wp-confirm-modal__head .wp-modal__title { color: var(--wp-text); }
|
|
127
|
+
.wp-confirm-modal__text {
|
|
128
|
+
margin: 0;
|
|
129
|
+
color: var(--wp-text-muted);
|
|
130
|
+
line-height: 1.5;
|
|
131
|
+
}
|
|
132
|
+
.wp-confirm-modal__gate {
|
|
133
|
+
display: flex;
|
|
134
|
+
flex-direction: column;
|
|
135
|
+
gap: var(--wp-space-2);
|
|
136
|
+
margin-top: var(--wp-space-5);
|
|
137
|
+
color: var(--wp-text-muted);
|
|
138
|
+
font-size: var(--wp-text-sm);
|
|
139
|
+
}
|
|
140
|
+
.wp-confirm-modal__gate code {
|
|
141
|
+
padding: 0 4px;
|
|
142
|
+
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
|
143
|
+
background: var(--wp-bg-3);
|
|
144
|
+
color: var(--wp-text);
|
|
145
|
+
font-family: var(--wp-font-mono);
|
|
146
|
+
}
|
|
147
|
+
</style>
|