@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.
@@ -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 (falling back to today's first
112
- * togglable row / custom row when nothing is active). Every open and
113
- * every query edit starts again with no active row.
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
- /** Rows of the panel: EVERY option, filtered — never pruned by
223
- * selection (that is what makes "used / not used" visible).
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
- const filteredRows = computed<readonly HkTagOption[]>(() => {
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
- if (!q) return props.options;
236
- return props.options.filter((o) => {
237
- if (o.label.toLowerCase().includes(q)) return true;
238
- if (o.meta && o.meta.toLowerCase().includes(q)) return true;
239
- if (o.key.toLowerCase().includes(q)) return true;
240
- if (o.keywords && o.keywords.toLowerCase().includes(q)) return true;
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 one stop: the single spelling the ROW renders and
277
- * `aria-activedescendant` publishes, so the two can never drift. */
278
- function stopId(kind: "option" | "custom", index = 0): string {
279
- return kind === "custom" ? `${generatedId}-custom` : `${generatedId}-option-${index}`;
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[] = filteredRows.value.map((option, index) => ({
290
- id: stopId("option", index),
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: stopId("custom"), kind: "custom" });
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 togglable row, otherwise the custom row (an inert top row
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 rows = filteredRows.value;
426
- if (rows.length > 0 && !rowInert(rows[0])) {
427
- toggleRow(rows[0]);
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 rows = filteredRows.value;
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
- {tagEntries.value.map((entry) => (
561
- <HkTag
562
- key={entry.key}
563
- class="hk-tag-input-tag"
564
- size={tagSize.value}
565
- closable={!props.disabled}
566
- closeLabel={`${removeLabel} — ${entry.label}`}
567
- onClose={() => removeKey(entry.key)}
568
- >
569
- <span class="hk-tag-input-tag-text">{entry.label}</span>
570
- </HkTag>
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
- matchAnchorWidth
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.map((option, index) => {
682
- const selected = selectedKeys.value.includes(option.key);
683
- const inert = rowInert(option);
684
- const stop = stops.value[index];
685
- return (
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
- key={option.key}
688
- id={stop.id}
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={selected}
693
- aria-disabled={inert || undefined}
694
- data-selected={selected || undefined}
695
- data-disabled={inert || undefined}
696
- data-active={activeId === stop.id || undefined}
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
- toggleRow(option);
1083
+ addCustom();
700
1084
  }}
701
1085
  >
702
1086
  <span class="hk-tag-input-check" aria-hidden="true">
703
- {selected && <Check size={14} />}
1087
+ <Plus size={14} />
704
1088
  </span>
705
- {option.flag && (
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
- {rows.length === 0 && !customVisible.value && (
1098
+ {visibleRows.length === 0 && !customVisible.value && (
749
1099
  <div class="hk-tag-input-empty">{emptyText}</div>
750
1100
  )}
751
1101
  </div>