@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/SortableList.vue
CHANGED
|
@@ -1,260 +1,260 @@
|
|
|
1
|
-
<script setup lang="ts" generic="T">
|
|
2
|
-
/**
|
|
3
|
-
* Sortable list: rows you put in order by dragging their grip, or from the
|
|
4
|
-
* keyboard. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
5
|
-
*
|
|
6
|
-
* <SortableList v-model="rules" :item-key="(r) => r.id" :item-label="(r) => r.name" label="Rules">
|
|
7
|
-
* <template #item="{ item }">…the row…</template>
|
|
8
|
-
* </SortableList>
|
|
9
|
-
*
|
|
10
|
-
* Mouse, pen and touch: drag the grip; a line shows where the row lands and
|
|
11
|
-
* Escape cancels. Keyboard (on the grip): Space or Enter picks the row up,
|
|
12
|
-
* the arrows move it, Space or Enter drops it, Escape puts it back; Alt+↑ /
|
|
13
|
-
* Alt+↓ move it one step without picking it up. Each step is said in a
|
|
14
|
-
* polite live line ("Hair moved to 2 of 5"). Only the grip starts a drag,
|
|
15
|
-
* so text and controls in the row work as usual.
|
|
16
|
-
*/
|
|
17
|
-
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useId } from "vue";
|
|
18
|
-
import { dropIndex, moveItem } from "./sortable";
|
|
19
|
-
|
|
20
|
-
const props = withDefaults(defineProps<{
|
|
21
|
-
modelValue: T[];
|
|
22
|
-
/** A stable key per item. */
|
|
23
|
-
itemKey: (item: T) => string | number;
|
|
24
|
-
/** The item's name in the grip's label and the announcements. */
|
|
25
|
-
itemLabel?: ((item: T, index: number) => string) | undefined;
|
|
26
|
-
/** The list's name for screen readers. */
|
|
27
|
-
label: string;
|
|
28
|
-
disabled?: boolean | undefined;
|
|
29
|
-
size?: "sm" | "md" | undefined;
|
|
30
|
-
}>(), { size: "md" });
|
|
31
|
-
|
|
32
|
-
const emit = defineEmits<{
|
|
33
|
-
(e: "update:modelValue", v: T[]): void;
|
|
34
|
-
(e: "move", m: { from: number; to: number }): void;
|
|
35
|
-
}>();
|
|
36
|
-
|
|
37
|
-
const uid = useId();
|
|
38
|
-
const helpId = `wp-sortable-help-${uid}`;
|
|
39
|
-
const listEl = ref<HTMLElement | null>(null);
|
|
40
|
-
const said = ref("");
|
|
41
|
-
|
|
42
|
-
/** Pointer drag: which row, where it started, where it would land. */
|
|
43
|
-
const drag = ref<{ from: number; to: number; startY: number; dy: number; line: number } | null>(null);
|
|
44
|
-
let mids: number[] = [];
|
|
45
|
-
let tops: number[] = [];
|
|
46
|
-
let bottoms: number[] = [];
|
|
47
|
-
let listTop = 0;
|
|
48
|
-
|
|
49
|
-
/** Keyboard pick-up: the row held and the order before it was picked up. */
|
|
50
|
-
const held = shallowRef<{ key: string | number; before: T[] } | null>(null);
|
|
51
|
-
|
|
52
|
-
const nameOf = (item: T, i: number): string => props.itemLabel?.(item, i) ?? `Item ${i + 1}`;
|
|
53
|
-
const total = computed(() => props.modelValue.length);
|
|
54
|
-
|
|
55
|
-
function announce(text: string): void {
|
|
56
|
-
said.value = "";
|
|
57
|
-
void nextTick(() => { said.value = text; });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function rows(): HTMLElement[] {
|
|
61
|
-
return listEl.value ? [...listEl.value.querySelectorAll<HTMLElement>(":scope > .wp-sortable__row")] : [];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function focusGrip(key: string | number): void {
|
|
65
|
-
void nextTick(() => {
|
|
66
|
-
const i = props.modelValue.findIndex((it) => props.itemKey(it) === key);
|
|
67
|
-
rows()[i]?.querySelector<HTMLElement>(".wp-sortable__grip")?.focus();
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function commit(from: number, to: number): void {
|
|
72
|
-
if (from === to) return;
|
|
73
|
-
emit("update:modelValue", moveItem(props.modelValue, from, to));
|
|
74
|
-
emit("move", { from, to });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// ── Pointer ─────────────────────────────────────────────────────────────
|
|
78
|
-
function onPointerDown(ev: PointerEvent, i: number): void {
|
|
79
|
-
if (props.disabled || ev.button !== 0 || held.value) return;
|
|
80
|
-
ev.preventDefault();
|
|
81
|
-
const rs = rows().map((r) => r.getBoundingClientRect());
|
|
82
|
-
mids = rs.map((r) => r.top + r.height / 2);
|
|
83
|
-
tops = rs.map((r) => r.top);
|
|
84
|
-
bottoms = rs.map((r) => r.bottom);
|
|
85
|
-
listTop = listEl.value?.getBoundingClientRect().top ?? 0;
|
|
86
|
-
drag.value = { from: i, to: i, startY: ev.clientY, dy: 0, line: -1 };
|
|
87
|
-
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
|
88
|
-
window.addEventListener("keydown", onDragKey, true);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function lineFor(from: number, to: number): number {
|
|
92
|
-
if (to === from) return -1;
|
|
93
|
-
// Insert before original row k (skipping the dragged one), or after the last.
|
|
94
|
-
const k = to < from ? to : to + 1;
|
|
95
|
-
return (k < tops.length ? tops[k] : bottoms[bottoms.length - 1]) - listTop;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function onPointerMove(ev: PointerEvent): void {
|
|
99
|
-
const d = drag.value;
|
|
100
|
-
if (!d) return;
|
|
101
|
-
const to = dropIndex(ev.clientY, mids, d.from);
|
|
102
|
-
drag.value = { ...d, dy: ev.clientY - d.startY, to, line: lineFor(d.from, to) };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function endDrag(ev: PointerEvent | null, keep: boolean): void {
|
|
106
|
-
const d = drag.value;
|
|
107
|
-
if (!d) return;
|
|
108
|
-
drag.value = null;
|
|
109
|
-
window.removeEventListener("keydown", onDragKey, true);
|
|
110
|
-
if (ev) (ev.currentTarget as HTMLElement | null)?.releasePointerCapture?.(ev.pointerId);
|
|
111
|
-
if (keep && d.to !== d.from) {
|
|
112
|
-
const item = props.modelValue[d.from];
|
|
113
|
-
commit(d.from, d.to);
|
|
114
|
-
announce(`${nameOf(item, d.from)} moved to ${d.to + 1} of ${total.value}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// A list removed mid-drag must not leave its Escape listener on the window.
|
|
119
|
-
onBeforeUnmount(() => window.removeEventListener("keydown", onDragKey, true));
|
|
120
|
-
|
|
121
|
-
function onDragKey(ev: KeyboardEvent): void {
|
|
122
|
-
if (ev.key !== "Escape") return;
|
|
123
|
-
ev.preventDefault();
|
|
124
|
-
ev.stopPropagation();
|
|
125
|
-
endDrag(null, false);
|
|
126
|
-
announce("Move cancelled");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ── Keyboard ────────────────────────────────────────────────────────────
|
|
130
|
-
function onGripKey(ev: KeyboardEvent, i: number): void {
|
|
131
|
-
if (props.disabled) return;
|
|
132
|
-
const item = props.modelValue[i];
|
|
133
|
-
const key = props.itemKey(item);
|
|
134
|
-
const up = ev.key === "ArrowUp" || ev.key === "ArrowLeft";
|
|
135
|
-
const down = ev.key === "ArrowDown" || ev.key === "ArrowRight";
|
|
136
|
-
|
|
137
|
-
if (!held.value) {
|
|
138
|
-
if (ev.key === " " || ev.key === "Enter") {
|
|
139
|
-
ev.preventDefault();
|
|
140
|
-
held.value = { key, before: [...props.modelValue] };
|
|
141
|
-
announce(`Picked up ${nameOf(item, i)}, ${i + 1} of ${total.value}. Arrows move it, Space drops it, Escape puts it back.`);
|
|
142
|
-
} else if (ev.altKey && (up || down)) {
|
|
143
|
-
ev.preventDefault();
|
|
144
|
-
const to = up ? i - 1 : i + 1;
|
|
145
|
-
if (to < 0 || to >= total.value) return;
|
|
146
|
-
commit(i, to);
|
|
147
|
-
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
148
|
-
focusGrip(key);
|
|
149
|
-
}
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (up || down || ev.key === "Home" || ev.key === "End") {
|
|
154
|
-
ev.preventDefault();
|
|
155
|
-
const to = ev.key === "Home" ? 0 : ev.key === "End" ? total.value - 1 : up ? i - 1 : i + 1;
|
|
156
|
-
if (to < 0 || to >= total.value || to === i) return;
|
|
157
|
-
commit(i, to);
|
|
158
|
-
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
159
|
-
focusGrip(key);
|
|
160
|
-
} else if (ev.key === " " || ev.key === "Enter") {
|
|
161
|
-
ev.preventDefault();
|
|
162
|
-
held.value = null;
|
|
163
|
-
announce(`${nameOf(item, i)} dropped at ${i + 1} of ${total.value}`);
|
|
164
|
-
} else if (ev.key === "Escape") {
|
|
165
|
-
ev.preventDefault();
|
|
166
|
-
ev.stopPropagation();
|
|
167
|
-
const before = held.value.before;
|
|
168
|
-
held.value = null;
|
|
169
|
-
if (before.some((it, j) => it !== props.modelValue[j])) emit("update:modelValue", before);
|
|
170
|
-
const back = before.findIndex((it) => props.itemKey(it) === key);
|
|
171
|
-
announce(`Move cancelled. ${nameOf(item, i)} is back at ${back + 1} of ${total.value}`);
|
|
172
|
-
focusGrip(key);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function onGripBlur(i: number): void {
|
|
177
|
-
// Moving the row re-inserts its element, which can blur the grip for a
|
|
178
|
-
// moment; only a real focus loss drops it.
|
|
179
|
-
void nextTick(() => {
|
|
180
|
-
if (!held.value) return;
|
|
181
|
-
const grip = rows()[props.modelValue.findIndex((it) => props.itemKey(it) === held.value?.key)]?.querySelector(".wp-sortable__grip");
|
|
182
|
-
if (grip && document.activeElement !== grip) {
|
|
183
|
-
const item = props.modelValue[i];
|
|
184
|
-
held.value = null;
|
|
185
|
-
if (item) announce(`${nameOf(item, i)} dropped`);
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
</script>
|
|
190
|
-
|
|
191
|
-
<template>
|
|
192
|
-
<div class="wp-sortable" :data-size="size" :data-dragging="drag ? 'true' : undefined">
|
|
193
|
-
<ul ref="listEl" class="wp-sortable__list" :aria-label="label">
|
|
194
|
-
<li
|
|
195
|
-
v-for="(item, i) in modelValue"
|
|
196
|
-
:key="itemKey(item)"
|
|
197
|
-
class="wp-sortable__row"
|
|
198
|
-
:data-dragged="drag?.from === i || undefined"
|
|
199
|
-
:data-held="held?.key === itemKey(item) || undefined"
|
|
200
|
-
:style="drag?.from === i ? { transform: `translateY(${drag.dy}px)` } : undefined"
|
|
201
|
-
>
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
class="wp-sortable__grip"
|
|
205
|
-
:aria-label="`Move ${nameOf(item, i)}`"
|
|
206
|
-
:aria-describedby="helpId"
|
|
207
|
-
:aria-pressed="held?.key === itemKey(item)"
|
|
208
|
-
:disabled="disabled"
|
|
209
|
-
@pointerdown="onPointerDown($event, i)"
|
|
210
|
-
@pointermove="onPointerMove"
|
|
211
|
-
@pointerup="endDrag($event, true)"
|
|
212
|
-
@pointercancel="endDrag($event, false)"
|
|
213
|
-
@keydown="onGripKey($event, i)"
|
|
214
|
-
@blur="onGripBlur(i)"
|
|
215
|
-
><i class="pi pi-bars" aria-hidden="true" /></button>
|
|
216
|
-
<div class="wp-sortable__body"><slot name="item" :item="item" :index="i" /></div>
|
|
217
|
-
</li>
|
|
218
|
-
<li v-if="drag && drag.line >= 0" class="wp-sortable__line" :style="{ top: `${drag.line}px` }" aria-hidden="true" />
|
|
219
|
-
</ul>
|
|
220
|
-
<span :id="helpId" class="wp-sortable__sr">Space or Enter picks it up, the arrows move it, Space drops it, Escape puts it back. Alt and an arrow moves it one step.</span>
|
|
221
|
-
<span class="wp-sortable__sr" aria-live="polite">{{ said }}</span>
|
|
222
|
-
</div>
|
|
223
|
-
</template>
|
|
224
|
-
|
|
225
|
-
<style scoped>
|
|
226
|
-
.wp-sortable { position: relative; min-width: 0; }
|
|
227
|
-
.wp-sortable__list { position: relative; display: flex; flex-direction: column; gap: var(--wp-space-2); margin: 0; padding: 0; list-style: none; }
|
|
228
|
-
.wp-sortable[data-size="sm"] .wp-sortable__list { gap: 2px; }
|
|
229
|
-
.wp-sortable__row {
|
|
230
|
-
display: flex; align-items: center; gap: var(--wp-space-2); min-width: 0;
|
|
231
|
-
border-radius: var(--wp-radius); background: var(--wp-bg-1);
|
|
232
|
-
}
|
|
233
|
-
.wp-sortable__row[data-dragged] {
|
|
234
|
-
position: relative; z-index: 2; opacity: 0.92; box-shadow: var(--wp-shadow-md);
|
|
235
|
-
outline: 1px solid var(--wp-border-strong); pointer-events: none;
|
|
236
|
-
}
|
|
237
|
-
.wp-sortable__row[data-held] { outline: 1.5px dashed var(--wp-accent); outline-offset: 1px; }
|
|
238
|
-
.wp-sortable__grip {
|
|
239
|
-
display: inline-grid; place-items: center; flex: none; align-self: stretch;
|
|
240
|
-
width: 24px; min-height: 24px; margin: 0; padding: 0; border: 0; border-radius: var(--wp-radius-sm);
|
|
241
|
-
background: transparent; color: var(--wp-text-dim); cursor: grab; touch-action: none;
|
|
242
|
-
}
|
|
243
|
-
.wp-sortable[data-size="sm"] .wp-sortable__grip { width: 20px; min-height: 20px; }
|
|
244
|
-
.wp-sortable__grip .pi { font-size: 11px; }
|
|
245
|
-
.wp-sortable[data-size="sm"] .wp-sortable__grip .pi { font-size: 10px; }
|
|
246
|
-
.wp-sortable__grip:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-row-hover); }
|
|
247
|
-
.wp-sortable__grip:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
248
|
-
.wp-sortable__grip[aria-pressed="true"] { color: var(--wp-accent-text); }
|
|
249
|
-
.wp-sortable__grip:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
250
|
-
.wp-sortable[data-dragging] .wp-sortable__grip { cursor: grabbing; }
|
|
251
|
-
.wp-sortable__body { flex: 1; min-width: 0; }
|
|
252
|
-
.wp-sortable__line {
|
|
253
|
-
position: absolute; left: 0; right: 0; height: 2px; margin-top: -1px; border-radius: min(1px, var(--wp-radius-cap, 999px));
|
|
254
|
-
background: var(--wp-accent); box-shadow: 0 0 0 2px var(--wp-accent-glow); pointer-events: none;
|
|
255
|
-
}
|
|
256
|
-
.wp-sortable__sr {
|
|
257
|
-
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
258
|
-
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
259
|
-
}
|
|
260
|
-
</style>
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
/**
|
|
3
|
+
* Sortable list: rows you put in order by dragging their grip, or from the
|
|
4
|
+
* keyboard. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
5
|
+
*
|
|
6
|
+
* <SortableList v-model="rules" :item-key="(r) => r.id" :item-label="(r) => r.name" label="Rules">
|
|
7
|
+
* <template #item="{ item }">…the row…</template>
|
|
8
|
+
* </SortableList>
|
|
9
|
+
*
|
|
10
|
+
* Mouse, pen and touch: drag the grip; a line shows where the row lands and
|
|
11
|
+
* Escape cancels. Keyboard (on the grip): Space or Enter picks the row up,
|
|
12
|
+
* the arrows move it, Space or Enter drops it, Escape puts it back; Alt+↑ /
|
|
13
|
+
* Alt+↓ move it one step without picking it up. Each step is said in a
|
|
14
|
+
* polite live line ("Hair moved to 2 of 5"). Only the grip starts a drag,
|
|
15
|
+
* so text and controls in the row work as usual.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useId } from "vue";
|
|
18
|
+
import { dropIndex, moveItem } from "./sortable";
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
modelValue: T[];
|
|
22
|
+
/** A stable key per item. */
|
|
23
|
+
itemKey: (item: T) => string | number;
|
|
24
|
+
/** The item's name in the grip's label and the announcements. */
|
|
25
|
+
itemLabel?: ((item: T, index: number) => string) | undefined;
|
|
26
|
+
/** The list's name for screen readers. */
|
|
27
|
+
label: string;
|
|
28
|
+
disabled?: boolean | undefined;
|
|
29
|
+
size?: "sm" | "md" | undefined;
|
|
30
|
+
}>(), { size: "md" });
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: "update:modelValue", v: T[]): void;
|
|
34
|
+
(e: "move", m: { from: number; to: number }): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const uid = useId();
|
|
38
|
+
const helpId = `wp-sortable-help-${uid}`;
|
|
39
|
+
const listEl = ref<HTMLElement | null>(null);
|
|
40
|
+
const said = ref("");
|
|
41
|
+
|
|
42
|
+
/** Pointer drag: which row, where it started, where it would land. */
|
|
43
|
+
const drag = ref<{ from: number; to: number; startY: number; dy: number; line: number } | null>(null);
|
|
44
|
+
let mids: number[] = [];
|
|
45
|
+
let tops: number[] = [];
|
|
46
|
+
let bottoms: number[] = [];
|
|
47
|
+
let listTop = 0;
|
|
48
|
+
|
|
49
|
+
/** Keyboard pick-up: the row held and the order before it was picked up. */
|
|
50
|
+
const held = shallowRef<{ key: string | number; before: T[] } | null>(null);
|
|
51
|
+
|
|
52
|
+
const nameOf = (item: T, i: number): string => props.itemLabel?.(item, i) ?? `Item ${i + 1}`;
|
|
53
|
+
const total = computed(() => props.modelValue.length);
|
|
54
|
+
|
|
55
|
+
function announce(text: string): void {
|
|
56
|
+
said.value = "";
|
|
57
|
+
void nextTick(() => { said.value = text; });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rows(): HTMLElement[] {
|
|
61
|
+
return listEl.value ? [...listEl.value.querySelectorAll<HTMLElement>(":scope > .wp-sortable__row")] : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function focusGrip(key: string | number): void {
|
|
65
|
+
void nextTick(() => {
|
|
66
|
+
const i = props.modelValue.findIndex((it) => props.itemKey(it) === key);
|
|
67
|
+
rows()[i]?.querySelector<HTMLElement>(".wp-sortable__grip")?.focus();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function commit(from: number, to: number): void {
|
|
72
|
+
if (from === to) return;
|
|
73
|
+
emit("update:modelValue", moveItem(props.modelValue, from, to));
|
|
74
|
+
emit("move", { from, to });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Pointer ─────────────────────────────────────────────────────────────
|
|
78
|
+
function onPointerDown(ev: PointerEvent, i: number): void {
|
|
79
|
+
if (props.disabled || ev.button !== 0 || held.value) return;
|
|
80
|
+
ev.preventDefault();
|
|
81
|
+
const rs = rows().map((r) => r.getBoundingClientRect());
|
|
82
|
+
mids = rs.map((r) => r.top + r.height / 2);
|
|
83
|
+
tops = rs.map((r) => r.top);
|
|
84
|
+
bottoms = rs.map((r) => r.bottom);
|
|
85
|
+
listTop = listEl.value?.getBoundingClientRect().top ?? 0;
|
|
86
|
+
drag.value = { from: i, to: i, startY: ev.clientY, dy: 0, line: -1 };
|
|
87
|
+
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
|
88
|
+
window.addEventListener("keydown", onDragKey, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function lineFor(from: number, to: number): number {
|
|
92
|
+
if (to === from) return -1;
|
|
93
|
+
// Insert before original row k (skipping the dragged one), or after the last.
|
|
94
|
+
const k = to < from ? to : to + 1;
|
|
95
|
+
return (k < tops.length ? tops[k] : bottoms[bottoms.length - 1]) - listTop;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function onPointerMove(ev: PointerEvent): void {
|
|
99
|
+
const d = drag.value;
|
|
100
|
+
if (!d) return;
|
|
101
|
+
const to = dropIndex(ev.clientY, mids, d.from);
|
|
102
|
+
drag.value = { ...d, dy: ev.clientY - d.startY, to, line: lineFor(d.from, to) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function endDrag(ev: PointerEvent | null, keep: boolean): void {
|
|
106
|
+
const d = drag.value;
|
|
107
|
+
if (!d) return;
|
|
108
|
+
drag.value = null;
|
|
109
|
+
window.removeEventListener("keydown", onDragKey, true);
|
|
110
|
+
if (ev) (ev.currentTarget as HTMLElement | null)?.releasePointerCapture?.(ev.pointerId);
|
|
111
|
+
if (keep && d.to !== d.from) {
|
|
112
|
+
const item = props.modelValue[d.from];
|
|
113
|
+
commit(d.from, d.to);
|
|
114
|
+
announce(`${nameOf(item, d.from)} moved to ${d.to + 1} of ${total.value}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A list removed mid-drag must not leave its Escape listener on the window.
|
|
119
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onDragKey, true));
|
|
120
|
+
|
|
121
|
+
function onDragKey(ev: KeyboardEvent): void {
|
|
122
|
+
if (ev.key !== "Escape") return;
|
|
123
|
+
ev.preventDefault();
|
|
124
|
+
ev.stopPropagation();
|
|
125
|
+
endDrag(null, false);
|
|
126
|
+
announce("Move cancelled");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Keyboard ────────────────────────────────────────────────────────────
|
|
130
|
+
function onGripKey(ev: KeyboardEvent, i: number): void {
|
|
131
|
+
if (props.disabled) return;
|
|
132
|
+
const item = props.modelValue[i];
|
|
133
|
+
const key = props.itemKey(item);
|
|
134
|
+
const up = ev.key === "ArrowUp" || ev.key === "ArrowLeft";
|
|
135
|
+
const down = ev.key === "ArrowDown" || ev.key === "ArrowRight";
|
|
136
|
+
|
|
137
|
+
if (!held.value) {
|
|
138
|
+
if (ev.key === " " || ev.key === "Enter") {
|
|
139
|
+
ev.preventDefault();
|
|
140
|
+
held.value = { key, before: [...props.modelValue] };
|
|
141
|
+
announce(`Picked up ${nameOf(item, i)}, ${i + 1} of ${total.value}. Arrows move it, Space drops it, Escape puts it back.`);
|
|
142
|
+
} else if (ev.altKey && (up || down)) {
|
|
143
|
+
ev.preventDefault();
|
|
144
|
+
const to = up ? i - 1 : i + 1;
|
|
145
|
+
if (to < 0 || to >= total.value) return;
|
|
146
|
+
commit(i, to);
|
|
147
|
+
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
148
|
+
focusGrip(key);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (up || down || ev.key === "Home" || ev.key === "End") {
|
|
154
|
+
ev.preventDefault();
|
|
155
|
+
const to = ev.key === "Home" ? 0 : ev.key === "End" ? total.value - 1 : up ? i - 1 : i + 1;
|
|
156
|
+
if (to < 0 || to >= total.value || to === i) return;
|
|
157
|
+
commit(i, to);
|
|
158
|
+
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
159
|
+
focusGrip(key);
|
|
160
|
+
} else if (ev.key === " " || ev.key === "Enter") {
|
|
161
|
+
ev.preventDefault();
|
|
162
|
+
held.value = null;
|
|
163
|
+
announce(`${nameOf(item, i)} dropped at ${i + 1} of ${total.value}`);
|
|
164
|
+
} else if (ev.key === "Escape") {
|
|
165
|
+
ev.preventDefault();
|
|
166
|
+
ev.stopPropagation();
|
|
167
|
+
const before = held.value.before;
|
|
168
|
+
held.value = null;
|
|
169
|
+
if (before.some((it, j) => it !== props.modelValue[j])) emit("update:modelValue", before);
|
|
170
|
+
const back = before.findIndex((it) => props.itemKey(it) === key);
|
|
171
|
+
announce(`Move cancelled. ${nameOf(item, i)} is back at ${back + 1} of ${total.value}`);
|
|
172
|
+
focusGrip(key);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onGripBlur(i: number): void {
|
|
177
|
+
// Moving the row re-inserts its element, which can blur the grip for a
|
|
178
|
+
// moment; only a real focus loss drops it.
|
|
179
|
+
void nextTick(() => {
|
|
180
|
+
if (!held.value) return;
|
|
181
|
+
const grip = rows()[props.modelValue.findIndex((it) => props.itemKey(it) === held.value?.key)]?.querySelector(".wp-sortable__grip");
|
|
182
|
+
if (grip && document.activeElement !== grip) {
|
|
183
|
+
const item = props.modelValue[i];
|
|
184
|
+
held.value = null;
|
|
185
|
+
if (item) announce(`${nameOf(item, i)} dropped`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<div class="wp-sortable" :data-size="size" :data-dragging="drag ? 'true' : undefined">
|
|
193
|
+
<ul ref="listEl" class="wp-sortable__list" :aria-label="label">
|
|
194
|
+
<li
|
|
195
|
+
v-for="(item, i) in modelValue"
|
|
196
|
+
:key="itemKey(item)"
|
|
197
|
+
class="wp-sortable__row"
|
|
198
|
+
:data-dragged="drag?.from === i || undefined"
|
|
199
|
+
:data-held="held?.key === itemKey(item) || undefined"
|
|
200
|
+
:style="drag?.from === i ? { transform: `translateY(${drag.dy}px)` } : undefined"
|
|
201
|
+
>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="wp-sortable__grip"
|
|
205
|
+
:aria-label="`Move ${nameOf(item, i)}`"
|
|
206
|
+
:aria-describedby="helpId"
|
|
207
|
+
:aria-pressed="held?.key === itemKey(item)"
|
|
208
|
+
:disabled="disabled"
|
|
209
|
+
@pointerdown="onPointerDown($event, i)"
|
|
210
|
+
@pointermove="onPointerMove"
|
|
211
|
+
@pointerup="endDrag($event, true)"
|
|
212
|
+
@pointercancel="endDrag($event, false)"
|
|
213
|
+
@keydown="onGripKey($event, i)"
|
|
214
|
+
@blur="onGripBlur(i)"
|
|
215
|
+
><i class="pi pi-bars" aria-hidden="true" /></button>
|
|
216
|
+
<div class="wp-sortable__body"><slot name="item" :item="item" :index="i" /></div>
|
|
217
|
+
</li>
|
|
218
|
+
<li v-if="drag && drag.line >= 0" class="wp-sortable__line" :style="{ top: `${drag.line}px` }" aria-hidden="true" />
|
|
219
|
+
</ul>
|
|
220
|
+
<span :id="helpId" class="wp-sortable__sr">Space or Enter picks it up, the arrows move it, Space drops it, Escape puts it back. Alt and an arrow moves it one step.</span>
|
|
221
|
+
<span class="wp-sortable__sr" aria-live="polite">{{ said }}</span>
|
|
222
|
+
</div>
|
|
223
|
+
</template>
|
|
224
|
+
|
|
225
|
+
<style scoped>
|
|
226
|
+
.wp-sortable { position: relative; min-width: 0; }
|
|
227
|
+
.wp-sortable__list { position: relative; display: flex; flex-direction: column; gap: var(--wp-space-2); margin: 0; padding: 0; list-style: none; }
|
|
228
|
+
.wp-sortable[data-size="sm"] .wp-sortable__list { gap: 2px; }
|
|
229
|
+
.wp-sortable__row {
|
|
230
|
+
display: flex; align-items: center; gap: var(--wp-space-2); min-width: 0;
|
|
231
|
+
border-radius: var(--wp-radius); background: var(--wp-bg-1);
|
|
232
|
+
}
|
|
233
|
+
.wp-sortable__row[data-dragged] {
|
|
234
|
+
position: relative; z-index: 2; opacity: 0.92; box-shadow: var(--wp-shadow-md);
|
|
235
|
+
outline: 1px solid var(--wp-border-strong); pointer-events: none;
|
|
236
|
+
}
|
|
237
|
+
.wp-sortable__row[data-held] { outline: 1.5px dashed var(--wp-accent); outline-offset: 1px; }
|
|
238
|
+
.wp-sortable__grip {
|
|
239
|
+
display: inline-grid; place-items: center; flex: none; align-self: stretch;
|
|
240
|
+
width: 24px; min-height: 24px; margin: 0; padding: 0; border: 0; border-radius: var(--wp-radius-sm);
|
|
241
|
+
background: transparent; color: var(--wp-text-dim); cursor: grab; touch-action: none;
|
|
242
|
+
}
|
|
243
|
+
.wp-sortable[data-size="sm"] .wp-sortable__grip { width: 20px; min-height: 20px; }
|
|
244
|
+
.wp-sortable__grip .pi { font-size: 11px; }
|
|
245
|
+
.wp-sortable[data-size="sm"] .wp-sortable__grip .pi { font-size: 10px; }
|
|
246
|
+
.wp-sortable__grip:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-row-hover); }
|
|
247
|
+
.wp-sortable__grip:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
248
|
+
.wp-sortable__grip[aria-pressed="true"] { color: var(--wp-accent-text); }
|
|
249
|
+
.wp-sortable__grip:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
250
|
+
.wp-sortable[data-dragging] .wp-sortable__grip { cursor: grabbing; }
|
|
251
|
+
.wp-sortable__body { flex: 1; min-width: 0; }
|
|
252
|
+
.wp-sortable__line {
|
|
253
|
+
position: absolute; left: 0; right: 0; height: 2px; margin-top: -1px; border-radius: min(1px, var(--wp-radius-cap, 999px));
|
|
254
|
+
background: var(--wp-accent); box-shadow: 0 0 0 2px var(--wp-accent-glow); pointer-events: none;
|
|
255
|
+
}
|
|
256
|
+
.wp-sortable__sr {
|
|
257
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
258
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
259
|
+
}
|
|
260
|
+
</style>
|