@celestia-island/hikari 0.43.7 → 0.43.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.43.7",
3
+ "version": "0.43.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -329,10 +329,12 @@
329
329
  * happened. The dimming stays LIGHT on purpose (0.85, the value of the
330
330
  * HkDraggableList/HkDraggableGrid drag ghost): the item must still read as
331
331
  * the one being moved, not fade out. `transform` reflows nothing — no
332
- * neighbour ever moves for it — and the axis rules below keep it out of the
333
- * drag's own geometry too. The raised shadow replaces the row's own
334
- * box-shadow faces for as long as the drag lasts (the keyboard cursor's
335
- * inset ring included: the lift is the live state).
332
+ * neighbour ever moves for it — and it cannot move the drag's own geometry
333
+ * either: the resolution reads the pressed item's line and midpoint from
334
+ * the layout captured when the press landed, before this lift is painted.
335
+ * The raised shadow replaces the row's own box-shadow faces for as long as
336
+ * the drag lasts (the keyboard cursor's inset ring included: the lift is
337
+ * the live state).
336
338
  *
337
339
  * Chips carry the state as classes (HkTag takes no arbitrary attributes),
338
340
  * panel rows as data attributes — both are assertable hooks that never
@@ -348,16 +350,17 @@
348
350
  }
349
351
 
350
352
  /* The scale runs ALONG each list's own drag axis — chips on X (a horizontal
351
- * strip), rows on Y (a vertical list). That split is TASTE, not geometry: a
352
- * small scale that follows the gesture keeps the lifted item tight in a
353
- * dense chip row. A drag resolves its landing slot from the items' LIVE
354
- * rects (usePointerReorder's `indexAt`): the item's MIDPOINT on the drag
355
- * axis picks the slot, and on the axis ACROSS it every item's span decides
356
- * which line the pointer is on — a filter that started out a no-op precisely
357
- * because every row spans the list's full width. The item being dragged may
358
- * not decide that line (a drop onto its own slot is already a no-op), so a
359
- * centre-anchored transform on it — along the axis or across it — leaves the
360
- * resolution exactly where it was. */
353
+ * strip), rows on Y (a vertical list): a small scale that follows the
354
+ * gesture keeps the lifted item tight in a dense chip row. WHICH axis it
355
+ * runs on is TASTE, not necessity — a drag resolves its landing slot from
356
+ * the items' live rects (usePointerReorder's `indexAt`), where every item's
357
+ * span on the axis ACROSS the drag decides which line the pointer is on and
358
+ * the item's MIDPOINT on the drag axis picks the slot, except that the item
359
+ * being dragged is measured by the geometry captured when the press landed,
360
+ * before this lift is painted: no transform on it, along the axis, across it
361
+ * or an isotropic `scale()`, can move the line it is dragged along. The line
362
+ * filter started out a no-op precisely because every row spans the list's
363
+ * full width. */
361
364
  .hk-tag-input-tag-dragging {
362
365
  transform: scaleX(1.03);
363
366
  }
@@ -53,13 +53,16 @@ function mountTagInput(opts: MountOptions = {}) {
53
53
  };
54
54
  const container = document.createElement("div");
55
55
  document.body.appendChild(container);
56
+ /** The host's own array — the component's `modelValue` comes straight
57
+ * from it, so a test can rewrite the order out of band (a host edit
58
+ * landing mid-gesture) instead of only through the component's emits. */
59
+ const model = ref<readonly string[]>(opts.modelValue ?? []);
56
60
  const Host = defineComponent({
57
61
  name: "HkTagInputHost",
58
62
  setup() {
59
- const value = ref<readonly string[]>(opts.modelValue ?? []);
60
63
  return () =>
61
64
  h(HkTagInput, {
62
- modelValue: value.value,
65
+ modelValue: model.value,
63
66
  options: opts.options ?? OPTIONS,
64
67
  allowCustom: opts.allowCustom ?? false,
65
68
  label: opts.label,
@@ -72,7 +75,7 @@ function mountTagInput(opts: MountOptions = {}) {
72
75
  emptyText: opts.emptyText,
73
76
  "onUpdate:modelValue": (keys: string[]) => {
74
77
  events.modelValue.push(keys);
75
- value.value = keys;
78
+ model.value = keys;
76
79
  },
77
80
  onAdd: (key: string) => events.add.push(key),
78
81
  onRemove: (key: string) => events.remove.push(key),
@@ -83,7 +86,7 @@ function mountTagInput(opts: MountOptions = {}) {
83
86
  const app = createApp(Host);
84
87
  app.mount(container);
85
88
  mounts.push({ app, container });
86
- return { events, container };
89
+ return { events, container, model };
87
90
  }
88
91
 
89
92
  async function settle(): Promise<void> {
@@ -1192,6 +1195,15 @@ function holdPointer(
1192
1195
  clientY: from.y,
1193
1196
  }),
1194
1197
  );
1198
+ movePointer(to, pointerType);
1199
+ }
1200
+
1201
+ /** Move an already-held pointer — a second step of a gesture whose press
1202
+ * (and its exact travel) the test wants to control. */
1203
+ function movePointer(
1204
+ to: { x: number; y: number },
1205
+ pointerType: "mouse" | "touch" = "mouse",
1206
+ ): void {
1195
1207
  window.dispatchEvent(
1196
1208
  new PointerEvent("pointermove", {
1197
1209
  bubbles: true,
@@ -1434,6 +1446,156 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1434
1446
  expect(rowLabels().slice(0, 3)).toEqual(["Technology", "Germany", "News"]);
1435
1447
  });
1436
1448
 
1449
+ it("leaves the order alone when a keyboard nudge lands under a held row drag", async () => {
1450
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1451
+ await openPanel(container);
1452
+ const selected = reorderableRows();
1453
+ pinStrip(selected, 40, "y");
1454
+ const search = searchInput();
1455
+ search.focus();
1456
+ pressKey(search, "ArrowDown");
1457
+ await settle();
1458
+ pressKey(search, "ArrowDown");
1459
+ await settle();
1460
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1461
+
1462
+ // A drag on the FIRST row is held while the cursor's row is nudged with
1463
+ // Alt+ArrowUp. The drag's landing slot is an index into the strip it
1464
+ // grabbed, so a reorder under the held pointer would leave the release
1465
+ // moving whatever now sits at that index — a different row, silently.
1466
+ // The chord is still consumed (Alt+Arrow is not a plain arrow), it just
1467
+ // has nothing to move while a pointer owns the order.
1468
+ holdPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
1469
+ await settle();
1470
+ pressKey(search, "ArrowUp", { altKey: true });
1471
+ await settle();
1472
+ expect(events.modelValue, "the chord emits nothing mid-drag").toEqual([]);
1473
+ expect(rowLabels().slice(0, 3), "the rows stayed where they were").toEqual([
1474
+ "News",
1475
+ "Technology",
1476
+ "Germany",
1477
+ ]);
1478
+ releasePointer({ x: 10, y: 130 });
1479
+ await settle();
1480
+
1481
+ // …and the drop moves the row the pointer actually grabbed.
1482
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1483
+ });
1484
+
1485
+ it("keeps the chord out of a press that has not become a drag yet", async () => {
1486
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1487
+ await openPanel(container);
1488
+ const selected = reorderableRows();
1489
+ pinStrip(selected, 40, "y");
1490
+ const search = searchInput();
1491
+ search.focus();
1492
+ pressKey(search, "ArrowDown");
1493
+ await settle();
1494
+ pressKey(search, "ArrowDown");
1495
+ await settle();
1496
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1497
+
1498
+ // The same chord, one step EARLIER: the pointer is down on the first
1499
+ // row's grip but has not travelled the few pixels that turn a press into
1500
+ // a drag, so nothing has been published yet. The press already owns the
1501
+ // strip, though — crossing the threshold after the chord had moved a row
1502
+ // would leave the drop resolving against a strip it never grabbed.
1503
+ holdPointer({ x: 10, y: 10 }, { x: 12, y: 11 }, grip(selected[0]));
1504
+ await settle();
1505
+ pressKey(search, "ArrowUp", { altKey: true });
1506
+ await settle();
1507
+ expect(events.modelValue, "the chord emits nothing under a live press").toEqual([]);
1508
+
1509
+ // Crossing the threshold now still moves the row the pointer grabbed.
1510
+ movePointer({ x: 10, y: 130 });
1511
+ releasePointer({ x: 10, y: 130 });
1512
+ await settle();
1513
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1514
+ });
1515
+
1516
+ it("keeps the chord out of a live CHIP drag too", async () => {
1517
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1518
+ await openPanel(container);
1519
+ const strip = tags(container);
1520
+ pinStrip(strip, 60, "x");
1521
+ const search = searchInput();
1522
+ search.focus();
1523
+ pressKey(search, "ArrowDown");
1524
+ await settle();
1525
+ pressKey(search, "ArrowDown");
1526
+ await settle();
1527
+ expect(activeRow()).toBe(rowByLabel("Technology"));
1528
+
1529
+ // The panel rows and the field's chips are two views of ONE array, so a
1530
+ // chord that reorders the rows edits the strip a chip drag is resolving
1531
+ // against — the drag has to hold the order whichever list it grabbed.
1532
+ holdPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
1533
+ await settle();
1534
+ pressKey(search, "ArrowUp", { altKey: true });
1535
+ await settle();
1536
+ expect(events.modelValue, "the chord emits nothing mid-drag").toEqual([]);
1537
+ expect(tagTexts(container), "the chips stayed where they were").toEqual([
1538
+ "News",
1539
+ "Technology",
1540
+ "Germany",
1541
+ ]);
1542
+ releasePointer({ x: 200, y: 10 });
1543
+ await settle();
1544
+
1545
+ // The chip the pointer grabbed is the one that moved.
1546
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1547
+ expect(tagTexts(container)).toEqual(["Technology", "Germany", "News"]);
1548
+ });
1549
+
1550
+ it("follows the row it grabbed when the host reorders the list under it", async () => {
1551
+ const { events, container, model } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1552
+ await openPanel(container);
1553
+ const selected = reorderableRows();
1554
+ pinStrip(selected, 40, "y");
1555
+
1556
+ holdPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
1557
+ await settle();
1558
+ // The host rewrites its own order out of band while the drag is held: the
1559
+ // index the drag landed at names a different row now, but the row the
1560
+ // pointer is holding is still the one being moved.
1561
+ model.value = ["de", "news", "tech"];
1562
+ await settle();
1563
+ expect(rowLabels().slice(0, 3)).toEqual(["Germany", "News", "Technology"]);
1564
+
1565
+ releasePointer({ x: 10, y: 130 });
1566
+ await settle();
1567
+
1568
+ // "News" — the row the pointer grabbed — moved to the slot it was dragged
1569
+ // to, in the strip's own new order: the released gesture is never
1570
+ // silently replaced by a move of whichever row slid into that index.
1571
+ expect(events.modelValue.at(-1)).toEqual(["de", "tech", "news"]);
1572
+ expect(tagTexts(container)).toEqual(["Germany", "Technology", "News"]);
1573
+ });
1574
+
1575
+ it("keeps a dragged chip on the chip it grabbed when a × removes another one mid-drag", async () => {
1576
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1577
+ const strip = tags(container);
1578
+ pinStrip(strip, 60, "x");
1579
+
1580
+ // A second pair of eyes on the same list: the × of the FIRST chip is
1581
+ // clicked while the SECOND is being dragged. The field re-lays out under
1582
+ // the held pointer and the dragged chip slides one index left — so the
1583
+ // index the press landed at names the wrong chip by the time the pointer
1584
+ // comes up, and the drop has to stay on the chip the pointer is holding.
1585
+ holdPointer({ x: 70, y: 10 }, { x: 200, y: 10 }, strip[1]);
1586
+ await settle();
1587
+ closeButtons(container)[0].click();
1588
+ await settle();
1589
+ expect(tagTexts(container), "the × landed").toEqual(["Technology", "Germany"]);
1590
+
1591
+ releasePointer({ x: 200, y: 10 });
1592
+ await settle();
1593
+
1594
+ // "Technology" — the grabbed chip — lands last, in the field's own order.
1595
+ expect(events.modelValue.at(-1)).toEqual(["de", "tech"]);
1596
+ expect(tagTexts(container)).toEqual(["Germany", "Technology"]);
1597
+ });
1598
+
1437
1599
  it("clamps a row drag to the selected group", async () => {
1438
1600
  const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1439
1601
  await openPanel(container);
@@ -2059,14 +2221,16 @@ describe("HkTagInput drag feedback and text selection", () => {
2059
2221
 
2060
2222
  it("scales each list ALONG its drag axis and never across it", () => {
2061
2223
  // The drop slot is resolved from the items' live rects
2062
- // (usePointerReorder.indexAt): the item's midpoint on the drag axis
2063
- // picks the slot, and every item's span on the axis ACROSS it decides
2064
- // which line the pointer is on. A scale is symmetric about the centre,
2065
- // so scaling along the drag axis changes neither — while growing across
2066
- // it widens the dragged item's band, and a pointer a few pixels outside
2067
- // the strip would then match that item alone: every sibling falls out of
2068
- // the line filter and the drag silently collapses to a no-op. CSS is the
2069
- // only place this can regress, so each hook's own transform is pinned.
2224
+ // (usePointerReorder.indexAt) except for the item being dragged, whose
2225
+ // line across the list and midpoint along it come from the geometry
2226
+ // captured when the press landed: the item's midpoint on the drag axis
2227
+ // picks the slot, and each item's span on the axis ACROSS it decides
2228
+ // which line the pointer is on. A transform ON the pressed item is
2229
+ // measured from that pre-paint geometry either way, so the lift stays on
2230
+ // the drag axis by TASTE — a small scale following the gesture keeps a
2231
+ // dense chip row tight — rather than because the resolution needs it.
2232
+ // CSS is the only place the lift can regress, so each hook's own
2233
+ // transform is pinned.
2070
2234
  const axisTransform = (hook: string): string | undefined =>
2071
2235
  scss.match(
2072
2236
  new RegExp(`${hook.replace(/[[\]]/g, "\\$&")}\\s*\\{\\s*transform:\\s*([^;]+);`),
@@ -155,11 +155,12 @@ type HkTagStop =
155
155
  * lift scales ALONG each list's drag axis by TASTE, not by necessity: a
156
156
  * small scale that follows the gesture keeps the lifted item's own
157
157
  * footprint tight in a dense chip row. The drop slot is resolved from
158
- * the items' live rects, but the dragged item never decides which line
159
- * the pointer is on (it is only ever a candidate in the slot loop, and
160
- * a drop onto its own slot is already a no-op), so a centre-preserving
161
- * transform on it — along the axis, across it, or an isotropic
162
- * `scale()` — leaves the resolution exactly where it was;
158
+ * the items' live rects except for the item being dragged: its line
159
+ * across the list and its midpoint along it come from the geometry
160
+ * captured when the press landed, before the lift is painted, so no
161
+ * transform on it — along the axis, across it, isotropic, or anchored
162
+ * on an edge rather than its centre — can move the slot the pointer
163
+ * resolves;
163
164
  * - a key in `modelValue` that the catalog does not carry (a custom
164
165
  * tag, or an option deleted since) degrades to its raw key as the
165
166
  * tag label — the same rule as HkAffixPicker.tagEntries. Such a key
@@ -452,12 +453,23 @@ export const HkTagInput = defineComponent({
452
453
  /** The panel's keyboard reorder: move the ACTIVE row one slot inside
453
454
  * the selected group (the same edit a row drag makes, without a
454
455
  * pointer). Only a selected row has a place in that order, the ends of
455
- * the group are hard stops, and `false` means the key was not ours to
456
- * consume. The cursor stays on the key it was on — the stops rebuild
457
- * in the new order, so it follows the row the user moved. */
456
+ * the group are hard stops, and `false` means there was nothing this
457
+ * key could move. The cursor stays on the key it was on — the stops
458
+ * rebuild in the new order, so it follows the row the user moved. */
458
459
  function nudgeActiveRow(delta: 1 | -1): boolean {
459
460
  const stop = activeStop.value;
460
461
  if (props.disabled || !stop || stop.kind !== "option") return false;
462
+ // A live press OWNS the order — either list's, and from the moment
463
+ // the press lands rather than from the moment it turns into a drag.
464
+ // The drag would survive a reorder (it moves the ELEMENT the press
465
+ // grabbed, re-reading its index as it goes), but the item's geometry
466
+ // is the one the press read: reordering the strip under a held pointer
467
+ // leaves the drag resolving against a place its item no longer
468
+ // occupies, which is how a gesture ends up quietly doing nothing. So
469
+ // the chord is inert while a pointer owns the list. It is still
470
+ // consumed by the caller either way (Alt+Arrow is not a plain arrow,
471
+ // and the browser must not act on it).
472
+ if (chipDrag.pressed.value || rowDrag.pressed.value) return false;
461
473
  const group = selectedRows.value;
462
474
  const from = group.findIndex((option) => option.key === stop.option.key);
463
475
  if (from < 0) return false;