@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Star a post: a toggle Button with a filled star when on and the star count
|
|
4
|
+
* beside the label. The app owns the request; this only shows the state and
|
|
5
|
+
* emits `toggle`.
|
|
6
|
+
*
|
|
7
|
+
* <StarButton :starred="post.starred_by_me" :count="post.star_count" :disabled="pending" @toggle="star" />
|
|
8
|
+
*/
|
|
9
|
+
import Button from "./Button.vue";
|
|
10
|
+
|
|
11
|
+
withDefaults(defineProps<{
|
|
12
|
+
starred: boolean;
|
|
13
|
+
count?: number | undefined;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
16
|
+
}>(), { count: undefined, disabled: false, size: "md" });
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ (e: "toggle"): void }>();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<Button
|
|
23
|
+
:icon="starred ? 'star-fill' : 'star'"
|
|
24
|
+
:pressed="starred"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
:size="size"
|
|
27
|
+
@click="emit('toggle')"
|
|
28
|
+
>
|
|
29
|
+
{{ starred ? "Starred" : "Star" }}
|
|
30
|
+
<span v-if="count !== undefined" class="wp-star-button__count">{{ count.toLocaleString() }}</span>
|
|
31
|
+
</Button>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.wp-star-button__count {
|
|
36
|
+
margin-left: 2px;
|
|
37
|
+
color: var(--wp-text-muted);
|
|
38
|
+
font-variant-numeric: tabular-nums;
|
|
39
|
+
}
|
|
40
|
+
</style>
|
package/src/Stepper.vue
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string">
|
|
2
|
+
/**
|
|
3
|
+
* Stepper: the "1 · 2 · 3" header of a job done in steps (the pack builder,
|
|
4
|
+
* an import, a setup). Done steps show a check, a step with a problem shows
|
|
5
|
+
* a mark, and the current one is lit. Works on the canvas and in the manager.
|
|
6
|
+
*
|
|
7
|
+
* <Stepper v-model="step" :steps="[{ id: 'describe', label: 'Describe' }, { id: 'plan', label: 'Plan', state: 'done' }]" clickable />
|
|
8
|
+
*
|
|
9
|
+
* It is an ordered list with `aria-current="step"` on the current step. With
|
|
10
|
+
* `clickable` each step is a button that emits `update:modelValue`; with
|
|
11
|
+
* `linear` only done steps and the next one can be picked.
|
|
12
|
+
*/
|
|
13
|
+
import { computed } from "vue";
|
|
14
|
+
|
|
15
|
+
export interface StepperStep<V extends string = string> {
|
|
16
|
+
id: V;
|
|
17
|
+
label: string;
|
|
18
|
+
/** A short line under the label. */
|
|
19
|
+
hint?: string | undefined;
|
|
20
|
+
/** done shows a check, error a mark. Steps before the current one count as done unless set. */
|
|
21
|
+
state?: "todo" | "done" | "error" | undefined;
|
|
22
|
+
disabled?: boolean | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
modelValue: T;
|
|
27
|
+
steps: StepperStep<T>[];
|
|
28
|
+
clickable?: boolean | undefined;
|
|
29
|
+
/** Only done steps and the one after the last done step can be picked. */
|
|
30
|
+
linear?: boolean | undefined;
|
|
31
|
+
orientation?: "horizontal" | "vertical" | undefined;
|
|
32
|
+
size?: "sm" | "md" | undefined;
|
|
33
|
+
ariaLabel?: string | undefined;
|
|
34
|
+
}>(), { orientation: "horizontal", size: "md", ariaLabel: "Steps" });
|
|
35
|
+
|
|
36
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: T): void }>();
|
|
37
|
+
|
|
38
|
+
const current = computed(() => props.steps.findIndex((s) => s.id === props.modelValue));
|
|
39
|
+
|
|
40
|
+
function stateOf(i: number): "todo" | "done" | "error" | "current" {
|
|
41
|
+
const s = props.steps[i];
|
|
42
|
+
if (s.state === "error") return "error";
|
|
43
|
+
if (i === current.value) return "current";
|
|
44
|
+
if (s.state) return s.state;
|
|
45
|
+
return i < current.value ? "done" : "todo";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The furthest step a linear stepper lets you reach. */
|
|
49
|
+
const reach = computed(() => {
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < props.steps.length && (stateOf(i) === "done" || i <= current.value)) i++;
|
|
52
|
+
return i;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function canPick(i: number): boolean {
|
|
56
|
+
const s = props.steps[i];
|
|
57
|
+
if (!props.clickable || s.disabled) return false;
|
|
58
|
+
return !props.linear || i <= reach.value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const STATE_WORD = { done: "done", error: "has a problem", current: "current step", todo: "" } as const;
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<ol class="wp-stepper" :data-orientation="orientation" :data-size="size" :aria-label="ariaLabel">
|
|
66
|
+
<li
|
|
67
|
+
v-for="(s, i) in steps"
|
|
68
|
+
:key="s.id"
|
|
69
|
+
class="wp-stepper__step"
|
|
70
|
+
:data-state="stateOf(i)"
|
|
71
|
+
:aria-current="i === current ? 'step' : undefined"
|
|
72
|
+
>
|
|
73
|
+
<component
|
|
74
|
+
:is="clickable ? 'button' : 'div'"
|
|
75
|
+
class="wp-stepper__body"
|
|
76
|
+
:type="clickable ? 'button' : undefined"
|
|
77
|
+
:disabled="clickable ? !canPick(i) : undefined"
|
|
78
|
+
@click="canPick(i) && s.id !== modelValue && emit('update:modelValue', s.id)"
|
|
79
|
+
>
|
|
80
|
+
<span class="wp-stepper__mark" aria-hidden="true">
|
|
81
|
+
<i v-if="stateOf(i) === 'done'" class="pi pi-check" />
|
|
82
|
+
<i v-else-if="stateOf(i) === 'error'" class="pi pi-exclamation-triangle" />
|
|
83
|
+
<template v-else>{{ i + 1 }}</template>
|
|
84
|
+
</span>
|
|
85
|
+
<span class="wp-stepper__text">
|
|
86
|
+
<span class="wp-stepper__label">{{ s.label }}</span>
|
|
87
|
+
<span v-if="s.hint" class="wp-stepper__hint">{{ s.hint }}</span>
|
|
88
|
+
<span v-if="STATE_WORD[stateOf(i)]" class="wp-stepper__sr">, {{ STATE_WORD[stateOf(i)] }}</span>
|
|
89
|
+
</span>
|
|
90
|
+
</component>
|
|
91
|
+
<span v-if="i < steps.length - 1" class="wp-stepper__line" aria-hidden="true" />
|
|
92
|
+
</li>
|
|
93
|
+
</ol>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<style scoped>
|
|
97
|
+
.wp-stepper {
|
|
98
|
+
display: flex; align-items: flex-start; gap: var(--wp-space-3);
|
|
99
|
+
margin: 0; padding: 0; list-style: none; min-width: 0;
|
|
100
|
+
font-family: var(--wp-font-sans);
|
|
101
|
+
}
|
|
102
|
+
.wp-stepper[data-orientation="vertical"] { flex-direction: column; gap: 0; }
|
|
103
|
+
.wp-stepper__step { display: flex; align-items: center; gap: var(--wp-space-3); flex: 1 1 0; min-width: 0; }
|
|
104
|
+
.wp-stepper__step:last-child { flex: 0 1 auto; }
|
|
105
|
+
.wp-stepper[data-orientation="vertical"] .wp-stepper__step { flex-direction: column; align-items: flex-start; gap: 0; width: 100%; }
|
|
106
|
+
.wp-stepper__body {
|
|
107
|
+
display: inline-flex; align-items: center; gap: var(--wp-space-3); min-width: 0;
|
|
108
|
+
margin: 0; padding: 2px var(--wp-space-2) 2px 2px; border: 0; border-radius: var(--wp-radius);
|
|
109
|
+
background: transparent; color: var(--wp-text-muted); font: inherit; text-align: left;
|
|
110
|
+
}
|
|
111
|
+
button.wp-stepper__body { cursor: pointer; }
|
|
112
|
+
button.wp-stepper__body:hover:not(:disabled) { background: var(--wp-row-hover); color: var(--wp-text); }
|
|
113
|
+
button.wp-stepper__body:disabled { cursor: default; }
|
|
114
|
+
button.wp-stepper__body:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
115
|
+
.wp-stepper__mark {
|
|
116
|
+
display: inline-grid; place-items: center; flex: none; box-sizing: border-box;
|
|
117
|
+
width: 24px; height: 24px; border-radius: min(50%, var(--wp-radius-cap, 999px));
|
|
118
|
+
border: 1.5px solid var(--wp-border-strong); background: var(--wp-bg-1);
|
|
119
|
+
color: var(--wp-text-muted); font: 600 var(--wp-text-xs)/1 var(--wp-font-sans); font-variant-numeric: tabular-nums;
|
|
120
|
+
}
|
|
121
|
+
.wp-stepper[data-size="sm"] .wp-stepper__mark { width: 20px; height: 20px; font-size: 10px; }
|
|
122
|
+
.wp-stepper__mark .pi { font-size: 10px; }
|
|
123
|
+
.wp-stepper__text { display: flex; flex-direction: column; min-width: 0; }
|
|
124
|
+
.wp-stepper__label { font-size: var(--wp-text-sm); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
125
|
+
.wp-stepper[data-size="sm"] .wp-stepper__label { font-size: var(--wp-text-xs); }
|
|
126
|
+
.wp-stepper__hint { font-size: var(--wp-text-xs); color: var(--wp-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
127
|
+
.wp-stepper__sr {
|
|
128
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
129
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
130
|
+
}
|
|
131
|
+
.wp-stepper__line { flex: 1; min-width: 12px; height: 1.5px; border-radius: min(1px, var(--wp-radius-cap, 999px)); background: var(--wp-border); }
|
|
132
|
+
.wp-stepper[data-orientation="vertical"] .wp-stepper__line { flex: none; width: 1.5px; min-width: 0; height: 16px; margin: 2px 0 2px 13px; }
|
|
133
|
+
.wp-stepper[data-orientation="vertical"][data-size="sm"] .wp-stepper__line { margin-left: 11px; }
|
|
134
|
+
.wp-stepper__step[data-state="done"] .wp-stepper__mark { border-color: var(--wp-accent); background: var(--wp-accent); color: var(--wp-bg); }
|
|
135
|
+
.wp-stepper__step[data-state="done"] .wp-stepper__line { background: var(--wp-accent); }
|
|
136
|
+
.wp-stepper__step[data-state="current"] .wp-stepper__mark { border-color: var(--wp-accent); color: var(--wp-accent-text); box-shadow: 0 0 0 3px var(--wp-accent-glow); }
|
|
137
|
+
.wp-stepper__step[data-state="current"] .wp-stepper__body { color: var(--wp-text); }
|
|
138
|
+
.wp-stepper__step[data-state="current"] .wp-stepper__label { font-weight: 600; }
|
|
139
|
+
.wp-stepper__step[data-state="error"] .wp-stepper__mark { border-color: var(--wp-danger); color: var(--wp-danger); }
|
|
140
|
+
.wp-stepper__step[data-state="error"] .wp-stepper__label { color: var(--wp-danger-text); }
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The "support the project" link cards on an About page: one card per way to
|
|
4
|
+
* give, each with an icon, a label, a line of detail and an outside-link mark.
|
|
5
|
+
* The apps keep the list itself (where the money goes is theirs to say) and
|
|
6
|
+
* pass it in, so the extension and the community site show the same cards.
|
|
7
|
+
*/
|
|
8
|
+
export interface SupportLink {
|
|
9
|
+
/** Stable id: the card's `data-kind`. */
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
detail?: string | undefined;
|
|
13
|
+
/** PrimeIcons class, `pi-heart`. */
|
|
14
|
+
icon: string;
|
|
15
|
+
href: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
withDefaults(defineProps<{ links: readonly SupportLink[]; label?: string | undefined }>(), {
|
|
19
|
+
label: "Ways to support the project",
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<ul class="wp-support" :aria-label="label">
|
|
25
|
+
<li v-for="link in links" :key="link.id">
|
|
26
|
+
<a class="wp-support__link" :data-kind="link.id" :href="link.href" target="_blank" rel="noopener">
|
|
27
|
+
<span class="wp-support__icon"><i :class="['pi', link.icon]" aria-hidden="true" /></span>
|
|
28
|
+
<span class="wp-support__text">
|
|
29
|
+
<span class="wp-support__label">{{ link.label }}</span>
|
|
30
|
+
<span v-if="link.detail" class="wp-support__detail">{{ link.detail }}</span>
|
|
31
|
+
</span>
|
|
32
|
+
<i class="pi pi-external-link wp-support__out" aria-hidden="true" />
|
|
33
|
+
<span class="wp-support__sr">(opens in a new tab)</span>
|
|
34
|
+
</a>
|
|
35
|
+
</li>
|
|
36
|
+
</ul>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<style scoped>
|
|
40
|
+
.wp-support {
|
|
41
|
+
display: grid;
|
|
42
|
+
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
|
43
|
+
gap: var(--wp-space-4);
|
|
44
|
+
margin: 0;
|
|
45
|
+
padding: 0;
|
|
46
|
+
list-style: none;
|
|
47
|
+
}
|
|
48
|
+
.wp-support__link {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: var(--wp-space-4);
|
|
52
|
+
height: 100%;
|
|
53
|
+
box-sizing: border-box;
|
|
54
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
55
|
+
border: 1px solid var(--wp-border);
|
|
56
|
+
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
|
57
|
+
background: var(--wp-bg-1);
|
|
58
|
+
color: var(--wp-text);
|
|
59
|
+
text-decoration: none;
|
|
60
|
+
transition: border-color var(--wp-motion-hover, 120ms) ease-out, background-color var(--wp-motion-hover, 120ms) ease-out;
|
|
61
|
+
}
|
|
62
|
+
.wp-support__link:hover {
|
|
63
|
+
border-color: var(--wp-accent-500);
|
|
64
|
+
background: color-mix(in oklab, var(--wp-accent-500) 8%, var(--wp-bg-1));
|
|
65
|
+
}
|
|
66
|
+
.wp-support__link:focus-visible {
|
|
67
|
+
outline: 2px solid var(--wp-accent-500);
|
|
68
|
+
outline-offset: 2px;
|
|
69
|
+
}
|
|
70
|
+
.wp-support__icon {
|
|
71
|
+
display: grid;
|
|
72
|
+
flex: none;
|
|
73
|
+
place-items: center;
|
|
74
|
+
width: 32px;
|
|
75
|
+
height: 32px;
|
|
76
|
+
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
|
77
|
+
background: var(--wp-bg-2);
|
|
78
|
+
color: var(--wp-accent-text);
|
|
79
|
+
font-size: 16px;
|
|
80
|
+
}
|
|
81
|
+
.wp-support__text { display: grid; gap: 2px; min-width: 0; flex: 1; }
|
|
82
|
+
.wp-support__label { font-weight: 600; font-size: var(--wp-text-sm); }
|
|
83
|
+
.wp-support__detail { font-size: var(--wp-text-xs); color: var(--wp-text-dim); }
|
|
84
|
+
.wp-support__out { color: var(--wp-text-dim); flex: none; font-size: 12px; }
|
|
85
|
+
.wp-support__sr {
|
|
86
|
+
position: absolute;
|
|
87
|
+
width: 1px;
|
|
88
|
+
height: 1px;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
clip-path: inset(50%);
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
package/src/TagInput.vue
ADDED
|
@@ -0,0 +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 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>
|
package/src/Textarea.vue
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, ref, watch } from "vue";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
modelValue: string;
|
|
6
|
+
rows?: number | undefined;
|
|
7
|
+
placeholder?: string | undefined;
|
|
8
|
+
autoResize?: boolean | undefined;
|
|
9
|
+
disabled?: boolean | undefined;
|
|
10
|
+
error?: boolean | undefined;
|
|
11
|
+
id?: string | undefined;
|
|
12
|
+
ariaLabel?: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
const props = withDefaults(defineProps<Props>(), { rows: 3 });
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: "update:modelValue", v: string): void;
|
|
18
|
+
(e: "blur", evt: FocusEvent): void;
|
|
19
|
+
(e: "focus", evt: FocusEvent): void;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const taRef = ref<HTMLTextAreaElement | null>(null);
|
|
23
|
+
|
|
24
|
+
function autosize() {
|
|
25
|
+
if (!props.autoResize) return;
|
|
26
|
+
const el = taRef.value;
|
|
27
|
+
if (!el) return;
|
|
28
|
+
el.style.height = "auto";
|
|
29
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function onInput(e: Event) {
|
|
33
|
+
emit("update:modelValue", (e.target as HTMLTextAreaElement).value);
|
|
34
|
+
if (props.autoResize) nextTick(autosize);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
watch(() => props.modelValue, () => {
|
|
38
|
+
if (props.autoResize) nextTick(autosize);
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<template>
|
|
43
|
+
<textarea
|
|
44
|
+
:id="id"
|
|
45
|
+
ref="taRef"
|
|
46
|
+
class="wp-textarea"
|
|
47
|
+
:rows="rows"
|
|
48
|
+
:value="modelValue"
|
|
49
|
+
:placeholder="placeholder"
|
|
50
|
+
:disabled="disabled"
|
|
51
|
+
:aria-label="ariaLabel"
|
|
52
|
+
:aria-invalid="error || undefined"
|
|
53
|
+
@input="onInput"
|
|
54
|
+
@blur="(e) => emit('blur', e)"
|
|
55
|
+
@focus="(e) => emit('focus', e)"
|
|
56
|
+
/>
|
|
57
|
+
</template>
|