@celestia-island/hikari 0.43.0 → 0.43.2
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/package.json +1 -1
- package/src/DemoApp.tsx +22 -0
- package/src/components/HkSelect.scss +34 -6
- package/src/components/HkSelectPanel.test.tsx +88 -0
- package/src/components/HkSelectPanel.tsx +65 -10
- package/src/components/HkTagInput.scss +84 -1
- package/src/components/HkTagInput.singleScroll.contract.test.ts +78 -5
- package/src/components/HkTagInput.test.tsx +813 -9
- package/src/components/HkTagInput.tsx +459 -109
- package/src/composables/usePointerReorder.test.ts +651 -0
- package/src/composables/usePointerReorder.ts +432 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
|
@@ -7,11 +7,13 @@ import {
|
|
|
7
7
|
type PropType,
|
|
8
8
|
} from "vue";
|
|
9
9
|
|
|
10
|
-
import { Check, ChevronDown, Plus, Search } from "lucide-vue-next";
|
|
10
|
+
import { Check, ChevronDown, GripVertical, Plus, Search } from "lucide-vue-next";
|
|
11
11
|
|
|
12
12
|
import { useI18n } from "../i18n/context";
|
|
13
|
+
import { usePointerReorder } from "../composables/usePointerReorder";
|
|
13
14
|
|
|
14
15
|
import HkInput from "./HkInput";
|
|
16
|
+
import HkListTransition from "./HkListTransition";
|
|
15
17
|
import HkSelectPanel from "./HkSelectPanel";
|
|
16
18
|
import HkTag from "./HkTag";
|
|
17
19
|
import "./HkTagInput.scss";
|
|
@@ -51,6 +53,29 @@ function isSubsequence(query: string, text: string): boolean {
|
|
|
51
53
|
const WIDE_GLYPH =
|
|
52
54
|
/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/;
|
|
53
55
|
|
|
56
|
+
/** Move the entry at `from` to the place of the entry at `to`, returning a
|
|
57
|
+
* fresh array — or null when the move says nothing (out of range, or the
|
|
58
|
+
* same slot). Insert-at-target semantics, which is what dropping one chip
|
|
59
|
+
* or one selected row ON another means: the moved key ends up at the
|
|
60
|
+
* target's index and the entries between them shift by one. */
|
|
61
|
+
function moveTo(list: readonly string[], from: number, to: number): string[] | null {
|
|
62
|
+
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const next = list.slice();
|
|
66
|
+
const [key] = next.splice(from, 1);
|
|
67
|
+
next.splice(to, 0, key);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The class HkListTransition paints on a node that is on its way OUT
|
|
72
|
+
* (`variant="reveal"` keeps it mounted, with pointer events off, until its
|
|
73
|
+
* squeeze-out ends). Such a node is no longer part of the list: it still
|
|
74
|
+
* sits in the DOM — and, under a test DOM, forever — so every strip a drag
|
|
75
|
+
* measures or indexes must skip it, or a drag started during a leave would
|
|
76
|
+
* count a ghost and move the wrong key. */
|
|
77
|
+
const LEAVING_CLASS = "hk-list-reveal-leave-active";
|
|
78
|
+
|
|
54
79
|
/** One keyboard stop of the panel: a visible catalog row, or the
|
|
55
80
|
* trailing custom row when `allowCustom` offers one (always last). */
|
|
56
81
|
type HkTagStop =
|
|
@@ -77,18 +102,59 @@ type HkTagStop =
|
|
|
77
102
|
* bottom sheet, outside-click/Escape closing and ONE SCROLLBAR PER
|
|
78
103
|
* WINDOW contract as every other picker. It deliberately mounts no
|
|
79
104
|
* scroll region of its own: the window surface scrolls the search
|
|
80
|
-
* field and the rows as one
|
|
105
|
+
* field and the rows as one. It also does NOT mirror the field's
|
|
106
|
+
* width (`matchAnchorWidth` is off, the HkAffixPicker precedent): the
|
|
107
|
+
* field is usually a full-width settings column while the catalog is
|
|
108
|
+
* designed at 13–19rem, so an anchor-wide box would leave a large
|
|
109
|
+
* empty band and park the surface's scrollbar far from the rows.
|
|
110
|
+
* Instead the surface is capped through `maxHeight`
|
|
111
|
+
* (`min(18rem, 45dvh)`), so a long catalog scrolls inside a panel
|
|
112
|
+
* that hugs its own measure rather than stretching to the viewport;
|
|
81
113
|
* - ALL options are listed, each with a check glyph when selected —
|
|
82
114
|
* unlike HkAffixPicker's multi mode, which hides the selected keys,
|
|
83
115
|
* "is this tag used or not" must stay visible and togglable in one
|
|
84
116
|
* pass. Toggling never closes the panel (batch editing);
|
|
117
|
+
* - ROWS ARE GROUP-STRICT: the selected options come first, in the
|
|
118
|
+
* host's `modelValue` order, then the unselected catalog options in
|
|
119
|
+
* catalog order, and the custom ("Use …") row is last. A query filters
|
|
120
|
+
* WITHIN the two groups — it can hide rows, never merge or re-sort
|
|
121
|
+
* them — and the keyboard stops (`stops`,
|
|
122
|
+
* `aria-activedescendant`) walk exactly this rendered order;
|
|
85
123
|
* - ORDER IS HOST STATE: a newly added key is APPENDED to the end of
|
|
86
124
|
* `modelValue` ("first tag = primary" for hosts that care), an
|
|
87
125
|
* existing key is removed in place, and the emitted array is always
|
|
88
126
|
* a copy of the host's order with that one edit applied;
|
|
127
|
+
* - REORDERING writes that same order back through
|
|
128
|
+
* `update:modelValue` (no add/remove fires — nothing was added or
|
|
129
|
+
* removed). Two drag surfaces, both with a ~6px movement threshold so
|
|
130
|
+
* a plain click/tap still clicks:
|
|
131
|
+
* * a CHIP is dragged by its BODY (its × never starts a drag, and
|
|
132
|
+
* pressing any control inside a chip is not a drag at all); the
|
|
133
|
+
* chip follows no ghost — the drop slot is highlighted and the
|
|
134
|
+
* release emits the reordered array;
|
|
135
|
+
* * in the PANEL only the SELECTED rows move, and only through the
|
|
136
|
+
* explicit GRIP HANDLE at the row's trailing edge (the handle is
|
|
137
|
+
* the one element that claims touch, so the sheet keeps scrolling
|
|
138
|
+
* normally everywhere else, and a press on it that never became a
|
|
139
|
+
* drag does not toggle its row). Holding the drag near the top or
|
|
140
|
+
* bottom of the panel's own scroll region pulls the list along a
|
|
141
|
+
* frame at a time, so a row can be carried past the visible band
|
|
142
|
+
* of the (capped) surface in one gesture; nothing is scrolled
|
|
143
|
+
* without a live drag. A row drag is CLAMPED to the selected
|
|
144
|
+
* group: unselected rows are not drop targets and never move, and
|
|
145
|
+
* a release past the group's end lands on its last position;
|
|
146
|
+
* both lists are keyed HkListTransition groups (FLIP `move`), so the
|
|
147
|
+
* moved chip/row and its neighbours animate into their new places,
|
|
148
|
+
* with the library's usual reduced-motion opt-outs;
|
|
89
149
|
* - a key in `modelValue` that the catalog does not carry (a custom
|
|
90
150
|
* tag, or an option deleted since) degrades to its raw key as the
|
|
91
|
-
* tag label — the same rule as HkAffixPicker.tagEntries
|
|
151
|
+
* tag label — the same rule as HkAffixPicker.tagEntries. Such a key
|
|
152
|
+
* has no panel row (the panel only lists the catalog), so a panel
|
|
153
|
+
* drag is applied to the host's array BY KEY: the moved key lands on
|
|
154
|
+
* the target key's position and whatever sat between them shifts by
|
|
155
|
+
* one — a custom tag is never dropped, duplicated, or addressed by
|
|
156
|
+
* an index the panel cannot see (and it can still be moved by its
|
|
157
|
+
* own chip, where it is a first-class entry);
|
|
92
158
|
* - removal is IMMEDIATE: a tag's × emits `remove` + `update:modelValue`
|
|
93
159
|
* with no confirm dialog. This is a deliberate divergence from
|
|
94
160
|
* HkAffixPicker's `confirmRemove` two-step — a tag field is edited
|
|
@@ -108,9 +174,23 @@ type HkTagStop =
|
|
|
108
174
|
* ends wrap — and publish it as `aria-activedescendant` on the inline
|
|
109
175
|
* input and on the panel's search field. That is the activedescendant
|
|
110
176
|
* pattern: focus never moves to a row, the row is only highlighted, and
|
|
111
|
-
* Enter activates the active row
|
|
112
|
-
*
|
|
113
|
-
*
|
|
177
|
+
* Enter activates the active row. With NO active row, Enter falls back to
|
|
178
|
+
* the first row the query can still ADD (a selected row is not an add
|
|
179
|
+
* target, so a blind Enter can never delete the tag the user is looking
|
|
180
|
+
* at), then to the custom row; a query naming only what the field already
|
|
181
|
+
* carries is a no-op. Every open and every query edit starts again with
|
|
182
|
+
* no active row.
|
|
183
|
+
*
|
|
184
|
+
* REORDERING IS ALSO A KEYBOARD AFFORDANCE: from the field's input, the
|
|
185
|
+
* panel's search box or a row that holds focus, Alt+ArrowUp /
|
|
186
|
+
* Alt+ArrowDown moves the ACTIVE selected row one slot inside the
|
|
187
|
+
* selected group (hard stops at its edges, a no-op for an unselected
|
|
188
|
+
* row). The grip handle is a pointer affordance; the field and the panel
|
|
189
|
+
* are otherwise pointer-only for reordering.
|
|
190
|
+
*
|
|
191
|
+
* `maxTags` freezes ADDS only. Reordering stays available at the cap:
|
|
192
|
+
* the order is host state, and a reorder adds nothing (nor removes
|
|
193
|
+
* anything).
|
|
114
194
|
*
|
|
115
195
|
* CONTROLLED: the component owns no selected state — `add` / `remove`
|
|
116
196
|
* fire with the key and `update:modelValue` with the full next array, and
|
|
@@ -219,33 +299,63 @@ export const HkTagInput = defineComponent({
|
|
|
219
299
|
}),
|
|
220
300
|
);
|
|
221
301
|
|
|
222
|
-
/**
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* Filtering is TWO-PASS over a single field at a time (never across
|
|
226
|
-
* concatenated fields, which produces noise):
|
|
302
|
+
/** Does one catalog entry survive the current query? Filtering is
|
|
303
|
+
* TWO-PASS over a single field at a time (never across concatenated
|
|
304
|
+
* fields, which produces noise):
|
|
227
305
|
* 1. an exact substring match for precision (label / meta / key /
|
|
228
306
|
* keywords, case-folded);
|
|
229
307
|
* 2. an in-order character subsequence fallback (gaps allowed) —
|
|
230
308
|
* so a renamed or CJK-composed label like "中华人民共和国" is
|
|
231
|
-
* still found by its short form "中国".
|
|
232
|
-
|
|
233
|
-
|
|
309
|
+
* still found by its short form "中国". */
|
|
310
|
+
function matchesQuery(option: HkTagOption, q: string): boolean {
|
|
311
|
+
if (!q) return true;
|
|
312
|
+
if (option.label.toLowerCase().includes(q)) return true;
|
|
313
|
+
if (option.meta && option.meta.toLowerCase().includes(q)) return true;
|
|
314
|
+
if (option.key.toLowerCase().includes(q)) return true;
|
|
315
|
+
if (option.keywords && option.keywords.toLowerCase().includes(q)) return true;
|
|
316
|
+
// Fuzzy fallback — per field, in-order subsequence, gaps allowed.
|
|
317
|
+
if (isSubsequence(q, option.label.toLowerCase())) return true;
|
|
318
|
+
if (option.meta && isSubsequence(q, option.meta.toLowerCase())) return true;
|
|
319
|
+
if (isSubsequence(q, option.key.toLowerCase())) return true;
|
|
320
|
+
return !!option.keywords && isSubsequence(q, option.keywords.toLowerCase());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Rows of the panel in its READING ORDER — every matching option,
|
|
324
|
+
* never pruned by selection (that is what makes "used / not used"
|
|
325
|
+
* visible), but GROUP-STRICT:
|
|
326
|
+
* 1. the selected options, in the host's `modelValue` order (the
|
|
327
|
+
* order a drag writes back — the selected group IS the host's
|
|
328
|
+
* array as far as this catalog can render it);
|
|
329
|
+
* 2. then the unselected options, in catalog order;
|
|
330
|
+
* 3. the custom row renders last, after this list.
|
|
331
|
+
*
|
|
332
|
+
* The query filters WITHIN each group: it can hide rows, it can never
|
|
333
|
+
* re-sort them or merge the two groups. */
|
|
334
|
+
const rows = computed<readonly HkTagOption[]>(() => {
|
|
234
335
|
const q = query.value.trim().toLowerCase();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
// Fuzzy fallback — per field, in-order subsequence, gaps allowed.
|
|
242
|
-
if (isSubsequence(q, o.label.toLowerCase())) return true;
|
|
243
|
-
if (o.meta && isSubsequence(q, o.meta.toLowerCase())) return true;
|
|
244
|
-
if (isSubsequence(q, o.key.toLowerCase())) return true;
|
|
245
|
-
return !!o.keywords && isSubsequence(q, o.keywords.toLowerCase());
|
|
336
|
+
const matched = props.options.filter((option) => matchesQuery(option, q));
|
|
337
|
+
if (selectedKeys.value.length === 0) return matched;
|
|
338
|
+
/** modelValue position of a key (a duplicated key keeps its first). */
|
|
339
|
+
const order = new Map<string, number>();
|
|
340
|
+
selectedKeys.value.forEach((key, index) => {
|
|
341
|
+
if (!order.has(key)) order.set(key, index);
|
|
246
342
|
});
|
|
343
|
+
const selected: HkTagOption[] = [];
|
|
344
|
+
const rest: HkTagOption[] = [];
|
|
345
|
+
for (const option of matched) {
|
|
346
|
+
(order.has(option.key) ? selected : rest).push(option);
|
|
347
|
+
}
|
|
348
|
+
selected.sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0));
|
|
349
|
+
return [...selected, ...rest];
|
|
247
350
|
});
|
|
248
351
|
|
|
352
|
+
/** The rows a drag may move, in display order — the selected group,
|
|
353
|
+
* which the render puts first, so index `i` here is display row `i`
|
|
354
|
+
* AND the i-th drop target of the panel strip. */
|
|
355
|
+
const selectedRows = computed<readonly HkTagOption[]>(() =>
|
|
356
|
+
rows.value.filter((option) => selectedKeys.value.includes(option.key)),
|
|
357
|
+
);
|
|
358
|
+
|
|
249
359
|
const customText = computed(() => query.value.trim());
|
|
250
360
|
|
|
251
361
|
/** An exact key OR label match means the query is naming an entry the
|
|
@@ -273,25 +383,33 @@ export const HkTagInput = defineComponent({
|
|
|
273
383
|
* already at capacity. */
|
|
274
384
|
const rowsDisabled = computed(() => props.disabled || atMax.value);
|
|
275
385
|
|
|
276
|
-
/** DOM id of
|
|
277
|
-
* `aria-activedescendant` publishes, so the two can never drift.
|
|
278
|
-
|
|
279
|
-
|
|
386
|
+
/** DOM id of a row stop: the single spelling the ROW renders and
|
|
387
|
+
* `aria-activedescendant` publishes, so the two can never drift.
|
|
388
|
+
*
|
|
389
|
+
* Keyed by the option's KEY rather than by its position, because a
|
|
390
|
+
* position is not stable: rows reorder (a drag, the group regroup, a
|
|
391
|
+
* query), and a leaving row keeps whatever id it had until its
|
|
392
|
+
* squeeze-out ends — a positional id would then be worn by two nodes
|
|
393
|
+
* at once and `aria-activedescendant` could resolve to the ghost.
|
|
394
|
+
* `encodeURIComponent` keeps an arbitrary host key inside one id; two
|
|
395
|
+
* catalog entries sharing a key are a host bug Vue already rejects
|
|
396
|
+
* (duplicate vnode keys). */
|
|
397
|
+
function optionStopId(option: HkTagOption): string {
|
|
398
|
+
return `${generatedId}-option-${encodeURIComponent(option.key)}`;
|
|
280
399
|
}
|
|
281
400
|
|
|
401
|
+
/** DOM id of the custom row — the one stop with no option behind it. */
|
|
402
|
+
const customStopId = `${generatedId}-custom`;
|
|
403
|
+
|
|
282
404
|
/** The stops ArrowDown / ArrowUp walk, in the panel's reading order:
|
|
283
|
-
* the visible rows, then the custom row as the LAST stop.
|
|
284
|
-
*
|
|
285
|
-
* Indexed by position, not by key — the filter rebuilds the list on
|
|
286
|
-
* every query edit (which clears the cursor) and an index can never
|
|
287
|
-
* collide the way a host-supplied key could inside a DOM id. */
|
|
405
|
+
* the visible rows, then the custom row as the LAST stop. */
|
|
288
406
|
const stops = computed<readonly HkTagStop[]>(() => {
|
|
289
|
-
const list: HkTagStop[] =
|
|
290
|
-
id:
|
|
407
|
+
const list: HkTagStop[] = rows.value.map((option) => ({
|
|
408
|
+
id: optionStopId(option),
|
|
291
409
|
kind: "option" as const,
|
|
292
410
|
option,
|
|
293
411
|
}));
|
|
294
|
-
if (customVisible.value) list.push({ id:
|
|
412
|
+
if (customVisible.value) list.push({ id: customStopId, kind: "custom" });
|
|
295
413
|
return list;
|
|
296
414
|
});
|
|
297
415
|
|
|
@@ -302,6 +420,37 @@ export const HkTagInput = defineComponent({
|
|
|
302
420
|
() => stops.value[activeIndex.value] ?? null,
|
|
303
421
|
);
|
|
304
422
|
|
|
423
|
+
/** The panel's keyboard reorder: move the ACTIVE row one slot inside
|
|
424
|
+
* the selected group (the same edit a row drag makes, without a
|
|
425
|
+
* pointer). Only a selected row has a place in that order, the ends of
|
|
426
|
+
* the group are hard stops, and `false` means the key was not ours to
|
|
427
|
+
* consume. The cursor stays on the key it was on — the stops rebuild
|
|
428
|
+
* in the new order, so it follows the row the user moved. */
|
|
429
|
+
function nudgeActiveRow(delta: 1 | -1): boolean {
|
|
430
|
+
const stop = activeStop.value;
|
|
431
|
+
if (props.disabled || !stop || stop.kind !== "option") return false;
|
|
432
|
+
const group = selectedRows.value;
|
|
433
|
+
const from = group.findIndex((option) => option.key === stop.option.key);
|
|
434
|
+
if (from < 0) return false;
|
|
435
|
+
const to = from + delta;
|
|
436
|
+
if (to < 0 || to >= group.length) return false;
|
|
437
|
+
reorderSelectedRows(from, to);
|
|
438
|
+
activeIndex.value = to;
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Alt+ArrowUp / Alt+ArrowDown on any of the field's keyboard surfaces
|
|
443
|
+
* is the reorder key: it is consumed even when the cursor has nowhere
|
|
444
|
+
* to go (an unselected or edge row), because Alt+Arrow is not a plain
|
|
445
|
+
* arrow — the browser must not act on it either. Returns whether the
|
|
446
|
+
* key belonged to the reorder. */
|
|
447
|
+
function onReorderKey(e: KeyboardEvent): boolean {
|
|
448
|
+
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return false;
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
nudgeActiveRow(e.key === "ArrowUp" ? -1 : 1);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
305
454
|
/** Walk the cursor one stop. From "nothing active" ArrowDown lands on
|
|
306
455
|
* the FIRST stop and ArrowUp on the last; from either end it wraps
|
|
307
456
|
* around. An empty list leaves nothing active. */
|
|
@@ -321,6 +470,20 @@ export const HkTagInput = defineComponent({
|
|
|
321
470
|
return option.label.trim() ? option.label : option.key;
|
|
322
471
|
}
|
|
323
472
|
|
|
473
|
+
/** Accessible name of a selected row's drag handle: it names the entry
|
|
474
|
+
* AND the action on the pointer surface (aria-label + title) instead of
|
|
475
|
+
* leaving a bare glyph there. The reorder itself is pointer-only — the
|
|
476
|
+
* row keeps its `option` role and its aria, the keyboard keeps the
|
|
477
|
+
* activedescendant cursor plus the × / Backspace removal, and the
|
|
478
|
+
* handle stays out of the tab order. (An option's children are
|
|
479
|
+
* presentational to AT, so the handle's own name is not announced;
|
|
480
|
+
* that is the accepted cost of keeping the row's existing roles.) */
|
|
481
|
+
function reorderLabel(option: HkTagOption): string {
|
|
482
|
+
return interpolate(t("hikari::tagInput.reorder", "Reorder {label}"), {
|
|
483
|
+
label: labelOf(option),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
324
487
|
/** Is this row inert? The field being disabled or full freezes the
|
|
325
488
|
* whole list (there is nothing to add); `disabled` on an option only
|
|
326
489
|
* forbids toggling it ON, so an already-set disabled option stays
|
|
@@ -396,6 +559,88 @@ export const HkTagInput = defineComponent({
|
|
|
396
559
|
toggle(option.key);
|
|
397
560
|
}
|
|
398
561
|
|
|
562
|
+
/** A chip dropped on another chip. The field's own left-to-right order
|
|
563
|
+
* IS the host's array order (tagEntries maps `modelValue` 1:1), so the
|
|
564
|
+
* two display indices are the array indices — the only edit is the
|
|
565
|
+
* move, and `update:modelValue` carries it. */
|
|
566
|
+
function reorderChips(from: number, to: number): void {
|
|
567
|
+
const next = moveTo(selectedKeys.value, from, to);
|
|
568
|
+
if (next) emit("update:modelValue", next);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** A selected row dropped on another selected row. The panel's group
|
|
572
|
+
* only carries keys the catalog lists, while `modelValue` may
|
|
573
|
+
* interleave custom ones — so the display indices are mapped through
|
|
574
|
+
* their KEYS and the move happens inside the host's own array: the
|
|
575
|
+
* moved key lands on the target key's position and whatever sat
|
|
576
|
+
* between the two shifts by one. A key the panel never showed is
|
|
577
|
+
* therefore neither dropped nor inserted at an index it never had. */
|
|
578
|
+
function reorderSelectedRows(from: number, to: number): void {
|
|
579
|
+
const group = selectedRows.value;
|
|
580
|
+
const fromKey = group[from]?.key;
|
|
581
|
+
const toKey = group[to]?.key;
|
|
582
|
+
if (fromKey === undefined || toKey === undefined) return;
|
|
583
|
+
const next = moveTo(
|
|
584
|
+
selectedKeys.value,
|
|
585
|
+
selectedKeys.value.indexOf(fromKey),
|
|
586
|
+
selectedKeys.value.indexOf(toKey),
|
|
587
|
+
);
|
|
588
|
+
if (next) emit("update:modelValue", next);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The panel surface, as the drag strip's query root: the selected
|
|
592
|
+
* rows are marked `data-reorder` and ONLY they are listed, so the
|
|
593
|
+
* drop space ends with the selected group — an unselected row can
|
|
594
|
+
* never be a landing slot (the clamp is structural, not a rule the
|
|
595
|
+
* drag has to remember). */
|
|
596
|
+
const panelRef = ref<HTMLElement | null>(null);
|
|
597
|
+
|
|
598
|
+
/** The chips the field CARRIES, in display order (the drag strip). A
|
|
599
|
+
* chip on its way out is not one of them — see LEAVING_CLASS. */
|
|
600
|
+
function liveChips(): HTMLElement[] {
|
|
601
|
+
return fieldRef.value
|
|
602
|
+
? [
|
|
603
|
+
...fieldRef.value.querySelectorAll<HTMLElement>(
|
|
604
|
+
`.hk-tag-input-tag:not(.${LEAVING_CLASS})`,
|
|
605
|
+
),
|
|
606
|
+
]
|
|
607
|
+
: [];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The selected rows, in display order (the panel's drag strip) — the
|
|
611
|
+
* same leaving-node rule. */
|
|
612
|
+
function liveReorderRows(): HTMLElement[] {
|
|
613
|
+
return panelRef.value
|
|
614
|
+
? [
|
|
615
|
+
...panelRef.value.querySelectorAll<HTMLElement>(
|
|
616
|
+
`[data-reorder="true"]:not(.${LEAVING_CLASS})`,
|
|
617
|
+
),
|
|
618
|
+
]
|
|
619
|
+
: [];
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const chipDrag = usePointerReorder({
|
|
623
|
+
axis: "x",
|
|
624
|
+
items: liveChips,
|
|
625
|
+
onDrop: reorderChips,
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const rowDrag = usePointerReorder({
|
|
629
|
+
axis: "y",
|
|
630
|
+
items: liveReorderRows,
|
|
631
|
+
// The panel's own scroll region — the desktop popout or the mobile
|
|
632
|
+
// sheet's list band, whichever the surface mounted — so a long drag
|
|
633
|
+
// carries a row past the visible band by scrolling the surface that
|
|
634
|
+
// already owns THE scrollbar (never a region of this component's
|
|
635
|
+
// own). Resolved per drag, because the form factor is decided by the
|
|
636
|
+
// viewport at open time.
|
|
637
|
+
scrollContainer: () =>
|
|
638
|
+
panelRef.value?.closest<HTMLElement>(
|
|
639
|
+
".hk-select-popout, .hk-select-sheet-list",
|
|
640
|
+
) ?? null,
|
|
641
|
+
onDrop: reorderSelectedRows,
|
|
642
|
+
});
|
|
643
|
+
|
|
399
644
|
/** The "Use “<query>”" row — the raw typed text becomes the tag. The
|
|
400
645
|
* query is consumed only when the tag was really added, so a no-op
|
|
401
646
|
* can never look like an accepted edit. */
|
|
@@ -409,8 +654,12 @@ export const HkTagInput = defineComponent({
|
|
|
409
654
|
/** Enter resolves the typed query. With an active row that row wins —
|
|
410
655
|
* the keyboard cursor must be able to reach a row other than the top
|
|
411
656
|
* one. With nothing active it stays the HkAffixPicker order: the
|
|
412
|
-
* first
|
|
413
|
-
* falls through instead of swallowing the key
|
|
657
|
+
* first row the query can still ADD, otherwise the custom row (an
|
|
658
|
+
* inert top row falls through instead of swallowing the key, and so
|
|
659
|
+
* does a row the field already carries — a blind Enter is an add
|
|
660
|
+
* gesture, and since selected rows now sort FIRST, toggling the top
|
|
661
|
+
* match would silently remove the very tag the user is looking at).
|
|
662
|
+
* Removing stays a deliberate act: the row itself, or the cursor.
|
|
414
663
|
* An empty query is a no-op: Enter on a blank field must not pick
|
|
415
664
|
* the top of the catalog. */
|
|
416
665
|
function commitQuery(): void {
|
|
@@ -422,14 +671,33 @@ export const HkTagInput = defineComponent({
|
|
|
422
671
|
}
|
|
423
672
|
const q = customText.value;
|
|
424
673
|
if (!q) return;
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
674
|
+
const addable = rows.value.find(
|
|
675
|
+
(option) => !rowInert(option) && !selectedKeys.value.includes(option.key),
|
|
676
|
+
);
|
|
677
|
+
if (addable) {
|
|
678
|
+
toggleRow(addable);
|
|
428
679
|
return;
|
|
429
680
|
}
|
|
430
681
|
if (customVisible.value) addCustom();
|
|
431
682
|
}
|
|
432
683
|
|
|
684
|
+
/** A press inside the field that lands on a CHIP starts a potential
|
|
685
|
+
* chip drag. Caught here on the box because the chip is rendered by
|
|
686
|
+
* HkTag (whose root takes no arbitrary attributes): the press's own
|
|
687
|
+
* target is resolved back to its chip, so the chip body is the handle
|
|
688
|
+
* while the × — a button — is vetoed inside the composable. A press
|
|
689
|
+
* on the input, the chevron or the empty space resolves to no chip
|
|
690
|
+
* and is none of the drag's business. */
|
|
691
|
+
function onFieldPointerDown(e: PointerEvent): void {
|
|
692
|
+
if (props.disabled) return;
|
|
693
|
+
const chip = (e.target as HTMLElement | null)?.closest?.(".hk-tag-input-tag");
|
|
694
|
+
if (!chip) return;
|
|
695
|
+
// The SAME strip the drop measures: a chip on its way out is not a
|
|
696
|
+
// slot, and counting it would shift every index behind it.
|
|
697
|
+
const index = liveChips().indexOf(chip as HTMLElement);
|
|
698
|
+
if (index >= 0) chipDrag.start(e, index);
|
|
699
|
+
}
|
|
700
|
+
|
|
433
701
|
function onFieldClick(e: MouseEvent): void {
|
|
434
702
|
if (props.disabled) return;
|
|
435
703
|
const target = e.target as HTMLElement | null;
|
|
@@ -444,6 +712,7 @@ export const HkTagInput = defineComponent({
|
|
|
444
712
|
|
|
445
713
|
function onInlineKeydown(e: KeyboardEvent): void {
|
|
446
714
|
if (e.isComposing) return;
|
|
715
|
+
if (onReorderKey(e)) return;
|
|
447
716
|
if (e.key === "Enter") {
|
|
448
717
|
e.preventDefault();
|
|
449
718
|
commitQuery();
|
|
@@ -481,6 +750,7 @@ export const HkTagInput = defineComponent({
|
|
|
481
750
|
|
|
482
751
|
function onSearchKeydown(e: KeyboardEvent): void {
|
|
483
752
|
if (e.isComposing) return;
|
|
753
|
+
if (onReorderKey(e)) return;
|
|
484
754
|
if (e.key === "Enter") {
|
|
485
755
|
e.preventDefault();
|
|
486
756
|
commitQuery();
|
|
@@ -504,7 +774,10 @@ export const HkTagInput = defineComponent({
|
|
|
504
774
|
function onPanelKeydown(e: KeyboardEvent): void {
|
|
505
775
|
if (e.isComposing) return;
|
|
506
776
|
const target = e.target as HTMLElement | null;
|
|
777
|
+
// A field's own arrows are that field's business — including the
|
|
778
|
+
// reorder chord, which onSearchKeydown/onInlineKeydown already took.
|
|
507
779
|
if (target?.closest?.("input, textarea, select")) return;
|
|
780
|
+
if (onReorderKey(e)) return;
|
|
508
781
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
509
782
|
e.preventDefault();
|
|
510
783
|
moveActive(e.key === "ArrowDown" ? 1 : -1);
|
|
@@ -512,7 +785,7 @@ export const HkTagInput = defineComponent({
|
|
|
512
785
|
}
|
|
513
786
|
|
|
514
787
|
return () => {
|
|
515
|
-
const
|
|
788
|
+
const visibleRows = rows.value;
|
|
516
789
|
const searchPlaceholder =
|
|
517
790
|
props.searchPlaceholder ?? t("hikari::tagInput.search", "Search");
|
|
518
791
|
const emptyText = props.emptyText ?? t("hikari::tagInput.empty", "No matches");
|
|
@@ -555,20 +828,40 @@ export const HkTagInput = defineComponent({
|
|
|
555
828
|
data-full={atMax.value || undefined}
|
|
556
829
|
role={inputRendered ? undefined : "group"}
|
|
557
830
|
aria-label={inputRendered ? undefined : title.value}
|
|
831
|
+
onPointerdown={onFieldPointerDown}
|
|
558
832
|
onClick={onFieldClick}
|
|
559
833
|
>
|
|
560
|
-
{
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
834
|
+
{/* The chips are a keyed list, so a reorder FLIPs the moved
|
|
835
|
+
* chip and its neighbours into place. `tag=""` renders the
|
|
836
|
+
* group as a FRAGMENT (Vue's TransitionGroup contract for an
|
|
837
|
+
* absent tag): no wrapper element appears in the field, so
|
|
838
|
+
* every chip stays a direct item of the box's wrapping row
|
|
839
|
+
* exactly as before. The press itself is caught on the BOX
|
|
840
|
+
* below (HkTag accepts no arbitrary attributes), which is why
|
|
841
|
+
* the drag markers are classes, not data attributes. */}
|
|
842
|
+
<HkListTransition tag="" variant="reveal" move>
|
|
843
|
+
{tagEntries.value.map((entry, index) => {
|
|
844
|
+
const dragging = chipDrag.dragFrom.value === index;
|
|
845
|
+
const dropTarget =
|
|
846
|
+
chipDrag.dragging.value && chipDrag.dragOver.value === index && !dragging;
|
|
847
|
+
return (
|
|
848
|
+
<HkTag
|
|
849
|
+
key={entry.key}
|
|
850
|
+
class={[
|
|
851
|
+
"hk-tag-input-tag",
|
|
852
|
+
dragging ? "hk-tag-input-tag-dragging" : "",
|
|
853
|
+
dropTarget ? "hk-tag-input-tag-drop" : "",
|
|
854
|
+
]}
|
|
855
|
+
size={tagSize.value}
|
|
856
|
+
closable={!props.disabled}
|
|
857
|
+
closeLabel={`${removeLabel} — ${entry.label}`}
|
|
858
|
+
onClose={() => removeKey(entry.key)}
|
|
859
|
+
>
|
|
860
|
+
<span class="hk-tag-input-tag-text">{entry.label}</span>
|
|
861
|
+
</HkTag>
|
|
862
|
+
);
|
|
863
|
+
})}
|
|
864
|
+
</HkListTransition>
|
|
572
865
|
{inputRendered ? (
|
|
573
866
|
<input
|
|
574
867
|
ref={inputRef}
|
|
@@ -633,7 +926,15 @@ export const HkTagInput = defineComponent({
|
|
|
633
926
|
anchorRef={fieldRef.value}
|
|
634
927
|
title={title.value}
|
|
635
928
|
placement="bottom-start"
|
|
636
|
-
|
|
929
|
+
// The panel hugs its own designed 13–19rem measure instead of
|
|
930
|
+
// stretching to the field's width (usually a full-width
|
|
931
|
+
// settings column): an anchor-wide surface would leave a band
|
|
932
|
+
// of empty popout beside the catalog and park the surface's
|
|
933
|
+
// scrollbar at the far edge of it. The cap keeps a long
|
|
934
|
+
// catalog scrolling inside that measure rather than growing
|
|
935
|
+
// toward the 36rem stylesheet ceiling.
|
|
936
|
+
matchAnchorWidth={false}
|
|
937
|
+
maxHeight="min(18rem, 45dvh)"
|
|
637
938
|
onUpdate:open={(v: boolean) => {
|
|
638
939
|
open.value = v;
|
|
639
940
|
}}
|
|
@@ -645,7 +946,7 @@ export const HkTagInput = defineComponent({
|
|
|
645
946
|
* popout / sheet) owns THE single scrollbar and scrolls the
|
|
646
947
|
* search field with the rows; no inner max-height/overflow
|
|
647
948
|
* here, ever. */
|
|
648
|
-
<div class="hk-tag-input-panel">
|
|
949
|
+
<div class="hk-tag-input-panel" ref={panelRef}>
|
|
649
950
|
<div class="hk-tag-input-search" role="search">
|
|
650
951
|
<HkInput
|
|
651
952
|
modelValue={query.value}
|
|
@@ -678,74 +979,123 @@ export const HkTagInput = defineComponent({
|
|
|
678
979
|
aria-multiselectable="true"
|
|
679
980
|
aria-label={title.value}
|
|
680
981
|
>
|
|
681
|
-
{rows
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
982
|
+
{/* The rows are a keyed list, so a reorder FLIPs the
|
|
983
|
+
* moved row and its neighbours. `tag=""` renders the
|
|
984
|
+
* group as a FRAGMENT (Vue's TransitionGroup contract
|
|
985
|
+
* for an absent tag): the rows stay the listbox's OWN
|
|
986
|
+
* children — still options of the listbox for
|
|
987
|
+
* aria-required-children, still the flex items that
|
|
988
|
+
* carry the option rhythm — with no wrapper element in
|
|
989
|
+
* between. */}
|
|
990
|
+
<HkListTransition tag="" variant="reveal" move>
|
|
991
|
+
{visibleRows.map((option, index) => {
|
|
992
|
+
const selected = selectedKeys.value.includes(option.key);
|
|
993
|
+
const inert = rowInert(option);
|
|
994
|
+
const stop = stops.value[index];
|
|
995
|
+
// Only a selected row is a drag item: it carries the
|
|
996
|
+
// marker the panel strip lists AND the grip that
|
|
997
|
+
// starts the drag. An unselected row stays put — not
|
|
998
|
+
// draggable, and not a drop target either.
|
|
999
|
+
const reorderable = selected && !props.disabled;
|
|
1000
|
+
const dragging = rowDrag.dragFrom.value === index;
|
|
1001
|
+
const dropTarget =
|
|
1002
|
+
rowDrag.dragging.value &&
|
|
1003
|
+
rowDrag.dragOver.value === index &&
|
|
1004
|
+
!dragging;
|
|
1005
|
+
return (
|
|
1006
|
+
<div
|
|
1007
|
+
key={option.key}
|
|
1008
|
+
id={stop.id}
|
|
1009
|
+
role="option"
|
|
1010
|
+
tabindex={-1}
|
|
1011
|
+
class="hk-tag-input-row"
|
|
1012
|
+
aria-selected={selected}
|
|
1013
|
+
aria-disabled={inert || undefined}
|
|
1014
|
+
data-selected={selected || undefined}
|
|
1015
|
+
data-disabled={inert || undefined}
|
|
1016
|
+
data-active={activeId === stop.id || undefined}
|
|
1017
|
+
data-reorder={reorderable ? "true" : undefined}
|
|
1018
|
+
data-dragging={dragging || undefined}
|
|
1019
|
+
data-drop={dropTarget || undefined}
|
|
1020
|
+
onClick={(e: MouseEvent) => {
|
|
1021
|
+
e.stopPropagation();
|
|
1022
|
+
toggleRow(option);
|
|
1023
|
+
}}
|
|
1024
|
+
>
|
|
1025
|
+
<span class="hk-tag-input-check" aria-hidden="true">
|
|
1026
|
+
{selected && <Check size={14} />}
|
|
1027
|
+
</span>
|
|
1028
|
+
{option.flag && (
|
|
1029
|
+
<span class="hk-tag-input-row-flag" aria-hidden="true">
|
|
1030
|
+
{option.flag}
|
|
1031
|
+
</span>
|
|
1032
|
+
)}
|
|
1033
|
+
<span class="hk-tag-input-row-label">{labelOf(option)}</span>
|
|
1034
|
+
{option.meta && (
|
|
1035
|
+
<span class="hk-tag-input-row-meta">{option.meta}</span>
|
|
1036
|
+
)}
|
|
1037
|
+
{reorderable && (
|
|
1038
|
+
<span
|
|
1039
|
+
class="hk-tag-input-grip"
|
|
1040
|
+
role="button"
|
|
1041
|
+
tabindex={-1}
|
|
1042
|
+
aria-label={reorderLabel(option)}
|
|
1043
|
+
title={reorderLabel(option)}
|
|
1044
|
+
// The handle — and only the handle — claims
|
|
1045
|
+
// the gesture (touch-action: none), so the
|
|
1046
|
+
// sheet keeps scrolling under every other
|
|
1047
|
+
// part of the row.
|
|
1048
|
+
onPointerdown={(e: PointerEvent) => {
|
|
1049
|
+
rowDrag.start(e, index);
|
|
1050
|
+
}}
|
|
1051
|
+
// A press on the handle that never became a
|
|
1052
|
+
// drag must not fall through to the row's
|
|
1053
|
+
// toggle: losing the tag the user reached
|
|
1054
|
+
// for is not what a handle means.
|
|
1055
|
+
onClick={(e: MouseEvent) => {
|
|
1056
|
+
e.stopPropagation();
|
|
1057
|
+
}}
|
|
1058
|
+
>
|
|
1059
|
+
<GripVertical size={14} aria-hidden="true" />
|
|
1060
|
+
</span>
|
|
1061
|
+
)}
|
|
1062
|
+
</div>
|
|
1063
|
+
);
|
|
1064
|
+
})}
|
|
1065
|
+
{customVisible.value && (
|
|
686
1066
|
<div
|
|
687
|
-
|
|
688
|
-
|
|
1067
|
+
id={customStopId}
|
|
1068
|
+
key="hk-tag-input-custom"
|
|
689
1069
|
role="option"
|
|
690
1070
|
tabindex={-1}
|
|
691
1071
|
class="hk-tag-input-row"
|
|
692
|
-
aria-selected={
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1072
|
+
aria-selected={false}
|
|
1073
|
+
// The custom row is inert on exactly the same flag
|
|
1074
|
+
// as the option rows — the whole list (custom row
|
|
1075
|
+
// included) freezes when the field is disabled or
|
|
1076
|
+
// full, and reads inert to AT while it does.
|
|
1077
|
+
aria-disabled={rowsDisabled.value || undefined}
|
|
1078
|
+
data-custom="true"
|
|
1079
|
+
data-disabled={rowsDisabled.value || undefined}
|
|
1080
|
+
data-active={activeId === customStopId || undefined}
|
|
697
1081
|
onClick={(e: MouseEvent) => {
|
|
698
1082
|
e.stopPropagation();
|
|
699
|
-
|
|
1083
|
+
addCustom();
|
|
700
1084
|
}}
|
|
701
1085
|
>
|
|
702
1086
|
<span class="hk-tag-input-check" aria-hidden="true">
|
|
703
|
-
|
|
1087
|
+
<Plus size={14} />
|
|
704
1088
|
</span>
|
|
705
|
-
{
|
|
706
|
-
<span class="hk-tag-input-row-flag" aria-hidden="true">
|
|
707
|
-
{option.flag}
|
|
708
|
-
</span>
|
|
709
|
-
)}
|
|
710
|
-
<span class="hk-tag-input-row-label">{labelOf(option)}</span>
|
|
711
|
-
{option.meta && (
|
|
712
|
-
<span class="hk-tag-input-row-meta">{option.meta}</span>
|
|
713
|
-
)}
|
|
1089
|
+
<span class="hk-tag-input-row-label">{customLabel}</span>
|
|
714
1090
|
</div>
|
|
715
|
-
)
|
|
716
|
-
|
|
717
|
-
{customVisible.value && (
|
|
718
|
-
<div
|
|
719
|
-
id={stopId("custom")}
|
|
720
|
-
role="option"
|
|
721
|
-
tabindex={-1}
|
|
722
|
-
class="hk-tag-input-row"
|
|
723
|
-
aria-selected={false}
|
|
724
|
-
// The custom row is inert on exactly the same flag
|
|
725
|
-
// as the option rows — the whole list (custom row
|
|
726
|
-
// included) freezes when the field is disabled or
|
|
727
|
-
// full, and reads inert to AT while it does.
|
|
728
|
-
aria-disabled={rowsDisabled.value || undefined}
|
|
729
|
-
data-custom="true"
|
|
730
|
-
data-disabled={rowsDisabled.value || undefined}
|
|
731
|
-
data-active={activeId === stopId("custom") || undefined}
|
|
732
|
-
onClick={(e: MouseEvent) => {
|
|
733
|
-
e.stopPropagation();
|
|
734
|
-
addCustom();
|
|
735
|
-
}}
|
|
736
|
-
>
|
|
737
|
-
<span class="hk-tag-input-check" aria-hidden="true">
|
|
738
|
-
<Plus size={14} />
|
|
739
|
-
</span>
|
|
740
|
-
<span class="hk-tag-input-row-label">{customLabel}</span>
|
|
741
|
-
</div>
|
|
742
|
-
)}
|
|
1091
|
+
)}
|
|
1092
|
+
</HkListTransition>
|
|
743
1093
|
</div>
|
|
744
1094
|
{/* The empty message lives OUTSIDE the listbox: a listbox
|
|
745
1095
|
* may only own options/groups (aria-required-children),
|
|
746
1096
|
* and the element it hangs off must still exist while the
|
|
747
1097
|
* panel is open for `aria-controls` to resolve. */}
|
|
748
|
-
{
|
|
1098
|
+
{visibleRows.length === 0 && !customVisible.value && (
|
|
749
1099
|
<div class="hk-tag-input-empty">{emptyText}</div>
|
|
750
1100
|
)}
|
|
751
1101
|
</div>
|