@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.
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkModal from "./HkModal";
5
+ import HkSelectPanel from "./HkSelectPanel";
5
6
  import HkTagInput, { type HkTagOption } from "./HkTagInput";
6
7
 
7
8
  /** Regional-indicator flag pair for an ISO 3166-1 alpha-2 code, built
@@ -114,8 +115,19 @@ function inlineInput(container: HTMLElement): HTMLInputElement | null {
114
115
  return container.querySelector<HTMLInputElement>(".hk-tag-input-element");
115
116
  }
116
117
 
118
+ /** HkListTransition keeps a leaving element mounted until its squeeze-out
119
+ * ends (`variant="reveal"`), and happy-dom never signals that end — so a
120
+ * node on its way out is still in the DOM. It is no longer part of the
121
+ * list (the CSS even takes its pointer events away), so every list query
122
+ * here reads the LIVING elements only. */
123
+ function isLeaving(el: Element): boolean {
124
+ return el.classList.contains("hk-list-reveal-leave-active");
125
+ }
126
+
117
127
  function tags(container: HTMLElement): HTMLElement[] {
118
- return [...container.querySelectorAll<HTMLElement>(".hk-tag")];
128
+ return [...container.querySelectorAll<HTMLElement>(".hk-tag")].filter(
129
+ (tag) => !isLeaving(tag),
130
+ );
119
131
  }
120
132
 
121
133
  function tagTexts(container: HTMLElement): string[] {
@@ -125,12 +137,22 @@ function tagTexts(container: HTMLElement): string[] {
125
137
  }
126
138
 
127
139
  function closeButtons(container: HTMLElement): HTMLButtonElement[] {
128
- return [...container.querySelectorAll<HTMLButtonElement>(".hk-tag-close")];
140
+ return tags(container)
141
+ .map((tag) => tag.querySelector<HTMLButtonElement>(".hk-tag-close"))
142
+ .filter((button): button is HTMLButtonElement => button != null);
129
143
  }
130
144
 
131
145
  /** Rows live in the teleported panel, so they are queried on body. */
132
146
  function rows(): HTMLElement[] {
133
- return [...document.querySelectorAll<HTMLElement>(".hk-tag-input-row")];
147
+ return [...document.querySelectorAll<HTMLElement>(".hk-tag-input-row")].filter(
148
+ (row) => !isLeaving(row),
149
+ );
150
+ }
151
+
152
+ /** The rows a panel drag may move: the selected ones, which carry both the
153
+ * strip marker and their grip handle. */
154
+ function reorderableRows(): HTMLElement[] {
155
+ return rows().filter((row) => row.hasAttribute("data-reorder"));
134
156
  }
135
157
 
136
158
  function rowLabels(): string[] {
@@ -149,11 +171,11 @@ function rowByLabel(label: string): HTMLElement {
149
171
 
150
172
  /** The row the keyboard cursor currently sits on (none = null). */
151
173
  function activeRow(): HTMLElement | null {
152
- return document.querySelector<HTMLElement>(".hk-tag-input-row[data-active]");
174
+ return rows().find((row) => row.hasAttribute("data-active")) ?? null;
153
175
  }
154
176
 
155
177
  function customRow(): HTMLElement | null {
156
- return document.querySelector<HTMLElement>('.hk-tag-input-row[data-custom="true"]');
178
+ return rows().find((row) => row.getAttribute("data-custom") === "true") ?? null;
157
179
  }
158
180
 
159
181
  function searchInput(): HTMLInputElement {
@@ -183,8 +205,10 @@ async function typeSearch(value: string): Promise<void> {
183
205
  await typeInto(searchInput(), value);
184
206
  }
185
207
 
186
- function pressKey(el: Element, key: string): void {
187
- el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
208
+ function pressKey(el: Element, key: string, init: KeyboardEventInit = {}): void {
209
+ el.dispatchEvent(
210
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }),
211
+ );
188
212
  }
189
213
 
190
214
  afterEach(async () => {
@@ -301,7 +325,7 @@ describe("HkTagInput", () => {
301
325
  const list = document.getElementById(id!);
302
326
  expect(list, "the referenced element exists").toBeTruthy();
303
327
  expect(list!.getAttribute("role")).toBe("listbox");
304
- expect(list!.children).toHaveLength(0);
328
+ expect([...list!.children].filter((child) => !isLeaving(child))).toHaveLength(0);
305
329
 
306
330
  // …and the message itself is NOT owned by the listbox: a listbox may
307
331
  // only own options/groups (aria-required-children), so it sits beside.
@@ -314,10 +338,13 @@ describe("HkTagInput", () => {
314
338
  it("lists EVERY option — selected ones stay visible with a check glyph", async () => {
315
339
  const { container } = mountTagInput({ modelValue: ["news", "tech"] });
316
340
  await openPanel(container);
341
+ // Group-strict: the two selected rows come first, in the HOST's order
342
+ // ("news" before "tech" — not the catalog's), then the unselected ones
343
+ // in catalog order.
317
344
  expect(rowLabels()).toEqual([
318
345
  "News",
319
- "中华人民共和国",
320
346
  "Technology",
347
+ "中华人民共和国",
321
348
  "Germany",
322
349
  "Retired",
323
350
  ]);
@@ -1120,3 +1147,780 @@ describe("HkTagInput", () => {
1120
1147
  ).toEqual(["News", "Technology"]);
1121
1148
  });
1122
1149
  });
1150
+
1151
+ /** Pin an item strip's rects: happy-dom ships no layout engine, so the
1152
+ * items a drag measures are placed by hand (the HkSlider pinRect
1153
+ * pattern). `size` is the item's extent along the axis. */
1154
+ function pinStrip(items: HTMLElement[], size: number, axis: "x" | "y"): void {
1155
+ items.forEach((el, index) => {
1156
+ const start = index * size;
1157
+ const box =
1158
+ axis === "x"
1159
+ ? { left: start, right: start + size, top: 0, bottom: size, width: size, height: size, x: start, y: 0 }
1160
+ : { left: 0, right: size, top: start, bottom: start + size, width: size, height: size, x: 0, y: start };
1161
+ Object.defineProperty(el, "getBoundingClientRect", {
1162
+ configurable: true,
1163
+ value: () => box as DOMRect,
1164
+ });
1165
+ });
1166
+ }
1167
+
1168
+ /** Press `on` and move to `to`, WITHOUT releasing — for observing a held
1169
+ * drag (auto-scroll, drop markers) before the drop. Moves and releases are
1170
+ * window events: the drag listens at the window, because a real pointer
1171
+ * leaves the item it started on. `buttons` models the held button (1 for
1172
+ * the whole press, 0 on release), which is how the drag tells a live
1173
+ * gesture from one whose release the page never saw. */
1174
+ function holdPointer(
1175
+ from: { x: number; y: number },
1176
+ to: { x: number; y: number },
1177
+ on: HTMLElement,
1178
+ pointerType: "mouse" | "touch" = "mouse",
1179
+ ): void {
1180
+ on.dispatchEvent(
1181
+ new PointerEvent("pointerdown", {
1182
+ bubbles: true,
1183
+ cancelable: true,
1184
+ pointerId: 1,
1185
+ pointerType,
1186
+ button: 0,
1187
+ buttons: 1,
1188
+ clientX: from.x,
1189
+ clientY: from.y,
1190
+ }),
1191
+ );
1192
+ window.dispatchEvent(
1193
+ new PointerEvent("pointermove", {
1194
+ bubbles: true,
1195
+ pointerId: 1,
1196
+ pointerType,
1197
+ buttons: 1,
1198
+ clientX: to.x,
1199
+ clientY: to.y,
1200
+ }),
1201
+ );
1202
+ }
1203
+
1204
+ function releasePointer(
1205
+ to: { x: number; y: number },
1206
+ pointerType: "mouse" | "touch" = "mouse",
1207
+ ): void {
1208
+ window.dispatchEvent(
1209
+ new PointerEvent("pointerup", {
1210
+ bubbles: true,
1211
+ pointerId: 1,
1212
+ pointerType,
1213
+ buttons: 0,
1214
+ clientX: to.x,
1215
+ clientY: to.y,
1216
+ }),
1217
+ );
1218
+ }
1219
+
1220
+ /** A full gesture: press, move, release. */
1221
+ function dragPointer(
1222
+ from: { x: number; y: number },
1223
+ to: { x: number; y: number },
1224
+ on: HTMLElement,
1225
+ pointerType: "mouse" | "touch" = "mouse",
1226
+ ): void {
1227
+ holdPointer(from, to, on, pointerType);
1228
+ releasePointer(to, pointerType);
1229
+ }
1230
+
1231
+ /** Wait out `count` animation frames — auto-scroll steps one per frame. */
1232
+ async function frames(count: number): Promise<void> {
1233
+ for (let i = 0; i < count; i += 1) {
1234
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1235
+ }
1236
+ }
1237
+
1238
+ /** Pin a scrollable band and place its drag rows relative to the band's own
1239
+ * scroll offset — what a browser does to the content a scroll moves under
1240
+ * the pointer (happy-dom has no layout, so the geometry is stated). */
1241
+ function pinScrollBand(
1242
+ surface: HTMLElement,
1243
+ height: number,
1244
+ rows: HTMLElement[],
1245
+ rowSize = 40,
1246
+ ): void {
1247
+ Object.defineProperty(surface, "getBoundingClientRect", {
1248
+ configurable: true,
1249
+ value: () =>
1250
+ ({ left: 0, right: 40, top: 0, bottom: height, width: 40, height, x: 0, y: 0 }) as DOMRect,
1251
+ });
1252
+ rows.forEach((row, index) => {
1253
+ Object.defineProperty(row, "getBoundingClientRect", {
1254
+ configurable: true,
1255
+ value: () => {
1256
+ const start = index * rowSize - surface.scrollTop;
1257
+ return {
1258
+ left: 0,
1259
+ right: 40,
1260
+ top: start,
1261
+ bottom: start + rowSize,
1262
+ width: 40,
1263
+ height: rowSize,
1264
+ x: 0,
1265
+ y: start,
1266
+ } as DOMRect;
1267
+ },
1268
+ });
1269
+ });
1270
+ }
1271
+
1272
+ /** The row the live drag would land on (the drop marker), or null. */
1273
+ function dropRow(): HTMLElement | null {
1274
+ return rows().find((row) => row.hasAttribute("data-drop")) ?? null;
1275
+ }
1276
+
1277
+ function grip(row: HTMLElement): HTMLElement {
1278
+ const handle = row.querySelector<HTMLElement>(".hk-tag-input-grip");
1279
+ expect(handle, "the selected row carries a drag handle").toBeTruthy();
1280
+ return handle!;
1281
+ }
1282
+
1283
+ function rowText(row: HTMLElement): string {
1284
+ return row.querySelector(".hk-tag-input-row-label")?.textContent ?? "";
1285
+ }
1286
+
1287
+ describe("HkTagInput panel geometry and drag reordering", () => {
1288
+ it("hugs its own measure instead of the field's width, capped by maxHeight", async () => {
1289
+ const { container } = mountTagInput({ modelValue: ["news"] });
1290
+ // A full-width settings column (880px): far wider than the panel's
1291
+ // designed 13–19rem measure, which is exactly the reported defect.
1292
+ Object.defineProperty(field(container), "getBoundingClientRect", {
1293
+ configurable: true,
1294
+ value: () =>
1295
+ ({ left: 0, right: 880, top: 0, bottom: 40, width: 880, height: 40, x: 0, y: 0 }) as DOMRect,
1296
+ });
1297
+ await openPanel(container);
1298
+
1299
+ const host = document.querySelector<HTMLElement>(".hk-select-popout-host")!;
1300
+ expect(host, "the popout host renders").toBeTruthy();
1301
+ // No anchor-width match: nothing pins the popout to the 880px field.
1302
+ expect(host.style.minWidth).toBe("");
1303
+
1304
+ // The surface cap rides the panel through the SCSS hook, so a long
1305
+ // catalog scrolls inside 18rem/45dvh instead of growing to 36rem.
1306
+ const popout = document.querySelector<HTMLElement>(".hk-select-popout")!;
1307
+ expect(popout.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
1308
+ "min(18rem, 45dvh)",
1309
+ );
1310
+ });
1311
+
1312
+ it("leaves the shared panel exactly as it was when the cap is not passed", async () => {
1313
+ // The `maxHeight` prop is ADDITIVE, pinned at the DOM level rather than
1314
+ // in source text: a panel mounted with no cap renders no hook and no
1315
+ // inline max-height, i.e. the historic popout geometry.
1316
+ const anchor = document.createElement("div");
1317
+ document.body.appendChild(anchor);
1318
+ const container = document.createElement("div");
1319
+ document.body.appendChild(container);
1320
+ const app = createApp({
1321
+ render: () =>
1322
+ h(
1323
+ HkSelectPanel,
1324
+ { open: true, anchorRef: anchor, title: "Pick" },
1325
+ { default: () => h("div", { class: "probe-row" }, "row") },
1326
+ ),
1327
+ });
1328
+ app.mount(container);
1329
+ mounts.push({ app, container });
1330
+ await settle();
1331
+
1332
+ const popout = document.querySelector<HTMLElement>(".hk-select-popout");
1333
+ expect(popout, "the popout renders").toBeTruthy();
1334
+ expect(popout!.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
1335
+ expect(popout!.style.maxHeight).toBe("");
1336
+ expect(popout!.hasAttribute("style"), "no inline style at all").toBe(false);
1337
+ });
1338
+
1339
+ it("regroups the rows: selected first in host order, then catalog order", async () => {
1340
+ // The host order is "de" then "news" — deliberately NOT the catalog's
1341
+ // (news precedes de there), so the assertion pins the host's order.
1342
+ const { container } = mountTagInput({ modelValue: ["de", "news"] });
1343
+ await openPanel(container);
1344
+ expect(rowLabels()).toEqual([
1345
+ "Germany",
1346
+ "News",
1347
+ "中华人民共和国",
1348
+ "Technology",
1349
+ "Retired",
1350
+ ]);
1351
+ // Only the selected rows are drag items — they carry the marker.
1352
+ expect(reorderableRows().map(rowText)).toEqual(["Germany", "News"]);
1353
+ });
1354
+
1355
+ it("filters WITHIN each group and never merges them", async () => {
1356
+ const { container } = mountTagInput({
1357
+ modelValue: ["tech", "news"],
1358
+ allowCustom: true,
1359
+ });
1360
+ await openPanel(container);
1361
+ // "e" matches News, Technology, Germany and Retired: the two selected
1362
+ // rows keep the host order and stay ahead of the unselected ones.
1363
+ await typeSearch("e");
1364
+ expect(rowLabels()).toEqual([
1365
+ "Technology",
1366
+ "News",
1367
+ "Germany",
1368
+ "Retired",
1369
+ "Use “e”",
1370
+ ]);
1371
+
1372
+ // A query matching only unselected rows leaves the selected group
1373
+ // empty — the groups do not collapse into one catalog order.
1374
+ await typeSearch("retir");
1375
+ expect(rowLabels()).toEqual(["Retired", "Use “retir”"]);
1376
+ });
1377
+
1378
+ it("walks the keyboard stops in the RENDERED order", async () => {
1379
+ const { container } = mountTagInput({ modelValue: ["de", "news"], allowCustom: true });
1380
+ await openPanel(container);
1381
+ const search = searchInput();
1382
+ search.focus();
1383
+ await typeSearch("e");
1384
+
1385
+ const walked: string[] = [];
1386
+ for (let i = 0; i < 5; i += 1) {
1387
+ pressKey(search, "ArrowDown");
1388
+ await settle();
1389
+ walked.push(rowText(activeRow()!));
1390
+ }
1391
+ // The cursor follows the regrouped rows, custom row last…
1392
+ expect(walked).toEqual(["Germany", "News", "Technology", "Retired", "Use “e”"]);
1393
+ // …and wraps back to the first rendered row.
1394
+ pressKey(search, "ArrowDown");
1395
+ await settle();
1396
+ expect(activeRow()).toBe(rowByLabel("Germany"));
1397
+ });
1398
+
1399
+ it("drags a chip by its body and emits the host's new order", async () => {
1400
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1401
+ const strip = tags(container);
1402
+ expect(strip).toHaveLength(3);
1403
+ pinStrip(strip, 60, "x");
1404
+
1405
+ // Press the first chip and pull the pointer past every midpoint: it
1406
+ // lands in the last slot.
1407
+ dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
1408
+ await settle();
1409
+
1410
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1411
+ expect(tagTexts(container)).toEqual(["Technology", "Germany", "News"]);
1412
+ // A reorder is not an edit: nothing was added or removed.
1413
+ expect(events.add).toEqual([]);
1414
+ expect(events.remove).toEqual([]);
1415
+ });
1416
+
1417
+ it("drags a selected row by its grip and emits the host's new order", async () => {
1418
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1419
+ await openPanel(container);
1420
+ const selected = reorderableRows();
1421
+ expect(selected.map(rowText)).toEqual(["News", "Technology", "Germany"]);
1422
+ pinStrip(selected, 40, "y");
1423
+
1424
+ dragPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
1425
+ await settle();
1426
+
1427
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1428
+ expect(events.add).toEqual([]);
1429
+ expect(events.remove).toEqual([]);
1430
+ // The panel and the field agree on the new order.
1431
+ expect(rowLabels().slice(0, 3)).toEqual(["Technology", "Germany", "News"]);
1432
+ });
1433
+
1434
+ it("clamps a row drag to the selected group", async () => {
1435
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1436
+ await openPanel(container);
1437
+ const selected = reorderableRows();
1438
+ expect(selected.map(rowText)).toEqual(["News", "Technology"]);
1439
+ // EVERY row is measured, not just the drag items: a strip that wrongly
1440
+ // included the unselected rows would otherwise resolve against
1441
+ // unmeasured (all-zero) rects and slip through this test.
1442
+ pinStrip(rows(), 40, "y");
1443
+
1444
+ // Aim far BELOW the group, deep into the unselected rows: the drop
1445
+ // still lands on the last SELECTED slot, never inside that region.
1446
+ dragPointer({ x: 10, y: 10 }, { x: 10, y: 600 }, grip(selected[0]));
1447
+ await settle();
1448
+
1449
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1450
+ expect(rowLabels()).toEqual([
1451
+ "Technology",
1452
+ "News",
1453
+ "中华人民共和国",
1454
+ "Germany",
1455
+ "Retired",
1456
+ ]);
1457
+ });
1458
+
1459
+ it("keeps unselected rows out of the drag space entirely", async () => {
1460
+ const { events, container } = mountTagInput({ modelValue: ["news"] });
1461
+ await openPanel(container);
1462
+ const selected = reorderableRows();
1463
+ expect(selected).toHaveLength(1);
1464
+ expect(grip(selected[0])).toBeTruthy();
1465
+
1466
+ // An unselected row carries neither the drag marker nor a handle…
1467
+ const unselected = rowByLabel("Germany");
1468
+ expect(unselected.hasAttribute("data-reorder")).toBe(false);
1469
+ expect(unselected.querySelector(".hk-tag-input-grip")).toBeNull();
1470
+
1471
+ // …and a press on it starts nothing, however far the pointer travels.
1472
+ pinStrip([selected[0], unselected], 40, "y");
1473
+ dragPointer({ x: 10, y: 70 }, { x: 10, y: 600 }, unselected);
1474
+ await settle();
1475
+ expect(events.modelValue).toEqual([]);
1476
+ });
1477
+
1478
+ it("moves a panel row by key, so a custom tag in the array survives", async () => {
1479
+ const { events, container } = mountTagInput({
1480
+ modelValue: ["news", "gitee.com", "tech"],
1481
+ });
1482
+ await openPanel(container);
1483
+ const selected = reorderableRows();
1484
+ // Only the two catalog entries have rows; the custom key does not.
1485
+ expect(selected.map(rowText)).toEqual(["News", "Technology"]);
1486
+ pinStrip(selected, 40, "y");
1487
+
1488
+ dragPointer({ x: 10, y: 10 }, { x: 10, y: 70 }, grip(selected[0]));
1489
+ await settle();
1490
+
1491
+ // "news" lands on "tech"'s host position; the custom key was neither
1492
+ // dropped nor duplicated by a row drag it had no row in.
1493
+ expect(events.modelValue.at(-1)).toEqual(["gitee.com", "tech", "news"]);
1494
+ expect(tagTexts(container)).toEqual(["gitee.com", "Technology", "News"]);
1495
+ });
1496
+
1497
+ it("keeps a sub-threshold press a click — and never drags from the ×", async () => {
1498
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1499
+ pinStrip(tags(container), 60, "x");
1500
+
1501
+ // The × is a control: its press starts no drag at all, however far the
1502
+ // pointer then travels — on touch just as on a mouse.
1503
+ dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, closeButtons(container)[0], "touch");
1504
+ await settle();
1505
+ expect(events.modelValue).toEqual([]);
1506
+
1507
+ // …and the click it does own still removes its tag.
1508
+ closeButtons(container)[0].click();
1509
+ await settle();
1510
+ expect(events.remove).toEqual(["news"]);
1511
+ expect(events.modelValue.at(-1)).toEqual(["tech"]);
1512
+
1513
+ // A press on the chip body that stays under the ~6px threshold is a
1514
+ // click too — no reorder, no swallowed click.
1515
+ const remaining = tags(container);
1516
+ pinStrip(remaining, 60, "x");
1517
+ dragPointer({ x: 10, y: 10 }, { x: 13, y: 10 }, remaining[0]);
1518
+ await settle();
1519
+ expect(events.modelValue.at(-1)).toEqual(["tech"]);
1520
+ expect(tagTexts(container)).toEqual(["Technology"]);
1521
+ });
1522
+
1523
+ it("swallows the click a real chip drag would deliver to the field", async () => {
1524
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1525
+ const strip = tags(container);
1526
+ pinStrip(strip, 60, "x");
1527
+
1528
+ dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
1529
+ // The gesture ended inside the field, so the browser delivers its click
1530
+ // to the box: the drag owns it — the panel must not pop open behind
1531
+ // the drop.
1532
+ field(container).dispatchEvent(new MouseEvent("click", { bubbles: true }));
1533
+ await settle();
1534
+
1535
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1536
+ expect(events.open).toEqual([]);
1537
+ });
1538
+
1539
+ it("does not let a row drag's trailing click toggle the dragged row", async () => {
1540
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1541
+ await openPanel(container);
1542
+ const selected = reorderableRows();
1543
+ pinStrip(selected, 40, "y");
1544
+ dragPointer({ x: 10, y: 10 }, { x: 10, y: 70 }, grip(selected[0]));
1545
+ // The pointer started and ended on the SAME row (a short drag), so the
1546
+ // browser delivers its click there right after the release: the drag
1547
+ // owns that click — otherwise the row the user just moved would toggle
1548
+ // itself back OFF against the model it was moved in.
1549
+ grip(selected[0]).dispatchEvent(new MouseEvent("click", { bubbles: true }));
1550
+ await settle();
1551
+
1552
+ expect(events.remove).toEqual([]);
1553
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1554
+ });
1555
+
1556
+ it("never lets a blind Enter remove a tag the query matches", async () => {
1557
+ const { events, container } = mountTagInput({ modelValue: ["tech"], allowCustom: true });
1558
+ await openPanel(container);
1559
+ const search = searchInput();
1560
+ search.focus();
1561
+
1562
+ // "e" matches the SELECTED Technology first (group-strict order), then
1563
+ // News / Germany / Retired. Enter with no cursor is the "type to add"
1564
+ // gesture, so it takes the first row the field can still take instead of
1565
+ // toggling the tag the user is looking at back off.
1566
+ await typeSearch("e");
1567
+ expect(rowLabels()[0]).toBe("Technology");
1568
+ pressKey(search, "Enter");
1569
+ await settle();
1570
+ expect(events.add).toEqual(["news"]);
1571
+ expect(events.remove).toEqual([]);
1572
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1573
+
1574
+ // A query naming ONLY what the field already carries has nothing to add
1575
+ // and nothing to remove — the same no-op rule a selected custom key
1576
+ // already had; the row stays for a deliberate toggle.
1577
+ await typeSearch("Technology");
1578
+ expect(rowLabels()).toEqual(["Technology"]);
1579
+ pressKey(search, "Enter");
1580
+ await settle();
1581
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1582
+ expect(events.remove).toEqual([]);
1583
+ expect(rowByLabel("Technology").getAttribute("aria-selected")).toBe("true");
1584
+ });
1585
+
1586
+ it("keeps a tap on the grip from toggling its row", async () => {
1587
+ const { events, container } = mountTagInput({ modelValue: ["news"] });
1588
+ await openPanel(container);
1589
+ const row = reorderableRows()[0];
1590
+ expect(rowText(row)).toBe("News");
1591
+
1592
+ // No drag happened (no movement): the press is the handle's, not the
1593
+ // row's — a handle that erased its tag on a tap would be a trap.
1594
+ grip(row).dispatchEvent(new MouseEvent("click", { bubbles: true }));
1595
+ await settle();
1596
+ expect(events.remove).toEqual([]);
1597
+ expect(events.modelValue).toEqual([]);
1598
+ expect(rowByLabel("News").getAttribute("aria-selected")).toBe("true");
1599
+ });
1600
+
1601
+ it("names the grip handle after the entry it moves", async () => {
1602
+ const { container } = mountTagInput({ modelValue: ["news", "tech"] });
1603
+ await openPanel(container);
1604
+ // The handle's name is the localized `hikari::tagInput.reorder`
1605
+ // template with the row's own label interpolated — the i18n key and
1606
+ // its `{label}` placeholder are part of the contract.
1607
+ const handle = grip(reorderableRows()[0]);
1608
+ expect(rowText(reorderableRows()[0])).toBe("News");
1609
+ expect(handle.getAttribute("aria-label")).toBe("Reorder News");
1610
+ expect(handle.getAttribute("title")).toBe("Reorder News");
1611
+ expect(handle.getAttribute("role")).toBe("button");
1612
+ expect(handle.getAttribute("tabindex"), "pointer-only affordance").toBe("-1");
1613
+ });
1614
+
1615
+ it("never lets a leaving row keep the id the field publishes", async () => {
1616
+ // A catalog edit (an async reload) drops the row the cursor sits on: the
1617
+ // row leaves through the list transition while the cursor stays on its
1618
+ // index, where a DIFFERENT option now lives. An index-derived id would
1619
+ // be worn by two nodes at once, so `aria-activedescendant` could resolve
1620
+ // to the ghost; the ids are keyed, so the published one belongs to
1621
+ // exactly one LIVING row.
1622
+ const container = document.createElement("div");
1623
+ document.body.appendChild(container);
1624
+ const value = ref<readonly string[]>(["news", "tech", "de"]);
1625
+ const options = ref<readonly HkTagOption[]>(OPTIONS);
1626
+ const Host = defineComponent({
1627
+ name: "HkTagInputCatalogShrinkHost",
1628
+ setup() {
1629
+ return () =>
1630
+ h(HkTagInput, {
1631
+ modelValue: value.value,
1632
+ options: options.value,
1633
+ "onUpdate:modelValue": (keys: string[]) => {
1634
+ value.value = keys;
1635
+ },
1636
+ });
1637
+ },
1638
+ });
1639
+ const app = createApp(Host);
1640
+ app.mount(container);
1641
+ mounts.push({ app, container });
1642
+ await settle();
1643
+
1644
+ chevron(container).click();
1645
+ await settle();
1646
+ pressKey(inlineInput(container)!, "ArrowDown");
1647
+ await settle();
1648
+ expect(activeRow()).toBe(rowByLabel("News"));
1649
+
1650
+ options.value = OPTIONS.filter((option) => option.key !== "news");
1651
+ await nextTick();
1652
+
1653
+ const published = inlineInput(container)!.getAttribute("aria-activedescendant");
1654
+ expect(published, "the field still publishes a row").toBeTruthy();
1655
+ const matches = document.querySelectorAll(`[id="${published}"]`);
1656
+ expect(matches, "the published id is worn by one row only").toHaveLength(1);
1657
+ const live = matches[0] as HTMLElement;
1658
+ expect(live.classList.contains("hk-list-reveal-leave-active")).toBe(false);
1659
+ expect(live.getAttribute("role")).toBe("option");
1660
+ expect(rowText(live)).toBe("Technology");
1661
+ });
1662
+
1663
+ it("carries a panel drag past the visible band by scrolling the popout", async () => {
1664
+ const { events, container } = mountTagInput({
1665
+ modelValue: ["news", "cn-main", "tech", "de", "retired"],
1666
+ });
1667
+ await openPanel(container);
1668
+ const selected = reorderableRows();
1669
+ expect(selected.map(rowText)).toEqual([
1670
+ "News",
1671
+ "中华人民共和国",
1672
+ "Technology",
1673
+ "Germany",
1674
+ "Retired",
1675
+ ]);
1676
+
1677
+ // A two-row visible band (80px) over the five-row strip: the last row is
1678
+ // only reachable by scrolling the surface — no new scroll region, the
1679
+ // popout that already owns the one scrollbar.
1680
+ const popout = document.querySelector<HTMLElement>(".hk-select-popout")!;
1681
+ expect(popout).toBeTruthy();
1682
+ pinScrollBand(popout, 80, selected);
1683
+
1684
+ // Press the first row and hold near the bottom edge.
1685
+ holdPointer({ x: 10, y: 10 }, { x: 10, y: 78 }, grip(selected[0]));
1686
+ await nextTick();
1687
+ expect(dropRow(), "before any scrolling the slot is inside the band").toBe(
1688
+ rowByLabel("中华人民共和国"),
1689
+ );
1690
+
1691
+ for (let i = 0; i < 40 && popout.scrollTop < 120; i += 1) await frames(1);
1692
+ expect(popout.scrollTop, "the surface is pulled along").toBeGreaterThanOrEqual(120);
1693
+ expect(dropRow(), "the slot follows the scrolled content").toBe(
1694
+ rowByLabel("Retired"),
1695
+ );
1696
+
1697
+ releasePointer({ x: 10, y: 78 });
1698
+ await settle();
1699
+ // The dragged key landed last, which is what the scroll bought.
1700
+ expect(events.modelValue.at(-1)).toEqual(["cn-main", "tech", "de", "retired", "news"]);
1701
+ expect(tagTexts(container).at(-1)).toBe("News");
1702
+
1703
+ // Nothing scrolls without a live drag.
1704
+ const settled = popout.scrollTop;
1705
+ await frames(3);
1706
+ expect(popout.scrollTop).toBe(settled);
1707
+ });
1708
+
1709
+ it("scrolls the mobile sheet's own list band, not the popout", async () => {
1710
+ const original = window.innerWidth;
1711
+ Object.defineProperty(window, "innerWidth", {
1712
+ configurable: true,
1713
+ writable: true,
1714
+ value: 375,
1715
+ });
1716
+ try {
1717
+ const { container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1718
+ await openPanel(container);
1719
+ // Phone width: the sheet is the surface, and the band that scrolls is
1720
+ // its list — resolved at drag time, not assumed.
1721
+ expect(document.querySelector(".hk-select-popout")).toBeNull();
1722
+ const band = document.querySelector<HTMLElement>(".hk-select-sheet-list");
1723
+ expect(band, "the sheet list renders").toBeTruthy();
1724
+ const selected = reorderableRows();
1725
+ pinScrollBand(band!, 80, selected);
1726
+
1727
+ holdPointer({ x: 10, y: 10 }, { x: 10, y: 78 }, grip(selected[0]));
1728
+ await nextTick();
1729
+ for (let i = 0; i < 40 && band!.scrollTop < 80; i += 1) await frames(1);
1730
+ expect(band!.scrollTop, "the sheet band is pulled along").toBeGreaterThanOrEqual(80);
1731
+ releasePointer({ x: 10, y: 78 });
1732
+ await settle();
1733
+ } finally {
1734
+ Object.defineProperty(window, "innerWidth", {
1735
+ configurable: true,
1736
+ writable: true,
1737
+ value: original,
1738
+ });
1739
+ }
1740
+ });
1741
+
1742
+ it("keeps a leaving chip out of the drag strip", async () => {
1743
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1744
+ // Measure the whole strip BEFORE the removal: the removed chip keeps
1745
+ // its rect while it squeezes out, so a drag that counted it would
1746
+ // resolve a different slot.
1747
+ pinStrip(tags(container), 60, "x");
1748
+ closeButtons(container)[0].click();
1749
+ await nextTick();
1750
+
1751
+ const live = tags(container);
1752
+ expect(live.map((tag) => tag.textContent)).toEqual(["Technology", "Germany"]);
1753
+ expect(
1754
+ container.querySelectorAll(".hk-tag").length,
1755
+ "the removed chip is still mounted (its leave)",
1756
+ ).toBe(3);
1757
+
1758
+ // Drag the FIRST LIVING chip to the end. Counting the ghost would take
1759
+ // the press for index 1 and emit the unchanged order instead.
1760
+ dragPointer({ x: 70, y: 10 }, { x: 300, y: 10 }, live[0]);
1761
+ await settle();
1762
+ expect(events.modelValue.at(-1)).toEqual(["de", "tech"]);
1763
+ expect(tagTexts(container)).toEqual(["Germany", "Technology"]);
1764
+ });
1765
+
1766
+ it("keeps a leaving row out of the panel drag strip", async () => {
1767
+ const { events, container } = mountTagInput({
1768
+ modelValue: ["news", "tech", "de", "cn-main"],
1769
+ });
1770
+ await openPanel(container);
1771
+ expect(reorderableRows().map(rowText)).toEqual([
1772
+ "News",
1773
+ "Technology",
1774
+ "Germany",
1775
+ "中华人民共和国",
1776
+ ]);
1777
+ // Measure every row up front — the row about to leave keeps its rect.
1778
+ pinStrip(rows(), 40, "y");
1779
+
1780
+ // "e" hides the selected 中华人民共和国: its row starts leaving and is
1781
+ // still in the DOM for the leave window, while the OTHER three remain
1782
+ // the drag strip. (One tick, not `settle()`: the ghost is guaranteed to
1783
+ // be there the moment the patch lands — which is when a real drag would
1784
+ // start — while later timers may already have swept it away.)
1785
+ const search = searchInput();
1786
+ search.value = "e";
1787
+ search.dispatchEvent(new Event("input", { bubbles: true }));
1788
+ await nextTick();
1789
+ expect(reorderableRows().map(rowText)).toEqual(["News", "Technology", "Germany"]);
1790
+ expect(
1791
+ document.querySelectorAll('.hk-tag-input-row[data-reorder="true"]').length,
1792
+ "the hidden row is still mounted (its leave)",
1793
+ ).toBe(4);
1794
+
1795
+ dragPointer({ x: 10, y: 10 }, { x: 10, y: 200 }, grip(reorderableRows()[0]));
1796
+ await settle();
1797
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news", "cn-main"]);
1798
+ expect(rowLabels().slice(0, 3)).toEqual(["Technology", "Germany", "News"]);
1799
+ });
1800
+
1801
+ it("moves the ACTIVE selected row with Alt+Arrow, and stops at the edges", async () => {
1802
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1803
+ await openPanel(container);
1804
+ const search = searchInput();
1805
+ search.focus();
1806
+ const edits = () => events.modelValue.length;
1807
+
1808
+ // Park the cursor on the middle selected row.
1809
+ pressKey(search, "ArrowDown");
1810
+ await settle();
1811
+ pressKey(search, "ArrowDown");
1812
+ await settle();
1813
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1814
+
1815
+ // Alt+ArrowUp moves it one slot up inside the selected group.
1816
+ pressKey(search, "ArrowUp", { altKey: true });
1817
+ await settle();
1818
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news", "de"]);
1819
+ expect(events.add).toEqual([]);
1820
+ expect(events.remove).toEqual([]);
1821
+ expect(rowLabels().slice(0, 3)).toEqual(["Technology", "News", "Germany"]);
1822
+ // The cursor followed the row it moved, it was not left behind.
1823
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1824
+
1825
+ // It is the group's first row now: another Alt+ArrowUp is a hard stop.
1826
+ pressKey(search, "ArrowUp", { altKey: true });
1827
+ await settle();
1828
+ expect(edits()).toBe(1);
1829
+
1830
+ // An UNSELECTED row has no place in the order — the chord does nothing.
1831
+ // Rows are [Technology, News, Germany, 中华人民共和国, Retired] now, and
1832
+ // the third of those is the first the field does not carry.
1833
+ pressKey(search, "ArrowDown");
1834
+ await settle();
1835
+ pressKey(search, "ArrowDown");
1836
+ await settle();
1837
+ pressKey(search, "ArrowDown");
1838
+ await settle();
1839
+ expect(activeRow()).toBe(rowByLabel("中华人民共和国"));
1840
+ expect(rowByLabel("中华人民共和国").hasAttribute("data-selected")).toBe(false);
1841
+ pressKey(search, "ArrowDown", { altKey: true });
1842
+ await settle();
1843
+ expect(edits()).toBe(1);
1844
+
1845
+ // …and with no row active at all there is nothing to move.
1846
+ pressKey(search, "Escape");
1847
+ await settle();
1848
+ await untilSettled(() => rows().length);
1849
+ await openPanel(container);
1850
+ expect(activeRow()).toBeNull();
1851
+ pressKey(searchInput(), "ArrowDown", { altKey: true });
1852
+ await settle();
1853
+ expect(edits()).toBe(1);
1854
+ });
1855
+
1856
+ it("reorders at the maxTags cap — the cap freezes adds, not the order", async () => {
1857
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"], maxTags: 2 });
1858
+ await openPanel(container);
1859
+ const search = searchInput();
1860
+ search.focus();
1861
+ pressKey(search, "ArrowDown");
1862
+ await settle();
1863
+ pressKey(search, "ArrowDown");
1864
+ await settle();
1865
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1866
+
1867
+ pressKey(search, "ArrowUp", { altKey: true });
1868
+ await settle();
1869
+ expect(events.modelValue.at(-1)).toEqual(["tech", "news"]);
1870
+ expect(events.add).toEqual([]);
1871
+ expect(tagTexts(container)).toEqual(["Technology", "News"]);
1872
+ });
1873
+
1874
+ it("keeps every chip and row a DIRECT child of its container", async () => {
1875
+ // The FLIP groups render as fragments: no wrapper element may appear
1876
+ // between the box and its chips, or between the listbox and its rows.
1877
+ const { container } = mountTagInput({ modelValue: ["news", "tech"] });
1878
+ const box = field(container);
1879
+ for (const chip of tags(container)) expect(chip.parentElement).toBe(box);
1880
+ expect(inlineInput(container)!.parentElement).toBe(box);
1881
+
1882
+ await openPanel(container);
1883
+ const list = document.querySelector<HTMLElement>(".hk-tag-input-list")!;
1884
+ expect(list.getAttribute("role")).toBe("listbox");
1885
+ expect(rows()).toHaveLength(OPTIONS.length);
1886
+ for (const row of rows()) expect(row.parentElement).toBe(list);
1887
+ });
1888
+
1889
+ it("caps the mobile sheet too — on the band that scrolls", async () => {
1890
+ const original = window.innerWidth;
1891
+ Object.defineProperty(window, "innerWidth", {
1892
+ configurable: true,
1893
+ writable: true,
1894
+ value: 375,
1895
+ });
1896
+ try {
1897
+ const { container } = mountTagInput({ modelValue: ["news"] });
1898
+ await openPanel(container);
1899
+ // Phone width: the sheet replaces the popout, and the same cap lands
1900
+ // on the sheet's scrolling list (the surface the user actually drags).
1901
+ expect(document.querySelector(".hk-select-popout-host")).toBeNull();
1902
+ const list = document.querySelector<HTMLElement>(".hk-select-sheet-list");
1903
+ expect(list, "the sheet list renders").toBeTruthy();
1904
+ expect(list!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
1905
+ "min(18rem, 45dvh)",
1906
+ );
1907
+ } finally {
1908
+ Object.defineProperty(window, "innerWidth", {
1909
+ configurable: true,
1910
+ writable: true,
1911
+ value: original,
1912
+ });
1913
+ }
1914
+ });
1915
+
1916
+ it("leaves a removed chip through the list-transition leave", async () => {
1917
+ // Both lists are HkListTransition groups (FLIP `move` + the reveal
1918
+ // squeeze): a removal squeezes the chip out instead of vanishing, and
1919
+ // the logical list is short immediately.
1920
+ const { container } = mountTagInput({ modelValue: ["news", "tech"] });
1921
+ closeButtons(container)[0].click();
1922
+ await nextTick();
1923
+ expect(container.querySelectorAll(".hk-tag.hk-list-reveal-leave-active")).toHaveLength(1);
1924
+ expect(tagTexts(container)).toEqual(["Technology"]);
1925
+ });
1926
+ });