@celestia-island/hikari 0.43.6 → 0.43.8

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.6",
3
+ "version": "0.43.8",
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
@@ -347,19 +349,18 @@
347
349
  transition: transform 0.12s ease, box-shadow 0.12s ease;
348
350
  }
349
351
 
350
- /* The scale runs ALONG each list's own drag axis, never across it. A drag
351
- * resolves its landing slot from the items' LIVE rects (usePointerReorder
352
- * .indexAt): the item's MIDPOINT on the drag axis picks the slot, and on
353
- * the axis ACROSS it every item's span decides which line the pointer is
354
- * on — a filter that started out a no-op precisely because every row spans
355
- * the list's full width. A scale is symmetric about the item's centre, so
356
- * scaling along the drag axis leaves both readings exactly as they were;
357
- * growing ACROSS it widens one item's band, and a pointer a few pixels
358
- * outside the strip (the row list's own padding is 6px) would then match
359
- * that item ALONE — every sibling falls out of the line filter, the slot
360
- * collapses back onto the item's own index, and the drag ends as a silent
361
- * no-op with no drop ring anywhere. Chips therefore scale on X (a
362
- * horizontal strip), rows on Y (a vertical list). */
352
+ /* The scale runs ALONG each list's own drag axis — chips on X (a horizontal
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. */
363
364
  .hk-tag-input-tag-dragging {
364
365
  transform: scaleX(1.03);
365
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,130 @@ 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("ends a row drag 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:
1559
+ // the index the drag resolves by no longer names the row it grabbed.
1560
+ model.value = ["de", "news", "tech"];
1561
+ await settle();
1562
+ expect(rowLabels().slice(0, 3)).toEqual(["Germany", "News", "Technology"]);
1563
+
1564
+ releasePointer({ x: 10, y: 130 });
1565
+ await settle();
1566
+
1567
+ // The gesture ends without a reorder — a drag may come to nothing, it
1568
+ // must never move whichever row slid into the index it was holding.
1569
+ expect(events.modelValue, "no reorder came out of the rewritten strip").toEqual([]);
1570
+ expect(tagTexts(container)).toEqual(["Germany", "News", "Technology"]);
1571
+ });
1572
+
1437
1573
  it("clamps a row drag to the selected group", async () => {
1438
1574
  const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1439
1575
  await openPanel(container);
@@ -2059,14 +2195,16 @@ describe("HkTagInput drag feedback and text selection", () => {
2059
2195
 
2060
2196
  it("scales each list ALONG its drag axis and never across it", () => {
2061
2197
  // 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.
2198
+ // (usePointerReorder.indexAt) except for the item being dragged, whose
2199
+ // line across the list and midpoint along it come from the geometry
2200
+ // captured when the press landed: the item's midpoint on the drag axis
2201
+ // picks the slot, and each item's span on the axis ACROSS it decides
2202
+ // which line the pointer is on. A transform ON the pressed item is
2203
+ // measured from that pre-paint geometry either way, so the lift stays on
2204
+ // the drag axis by TASTE — a small scale following the gesture keeps a
2205
+ // dense chip row tight — rather than because the resolution needs it.
2206
+ // CSS is the only place the lift can regress, so each hook's own
2207
+ // transform is pinned.
2070
2208
  const axisTransform = (hook: string): string | undefined =>
2071
2209
  scss.match(
2072
2210
  new RegExp(`${hook.replace(/[[\]]/g, "\\$&")}\\s*\\{\\s*transform:\\s*([^;]+);`),
@@ -152,10 +152,15 @@ type HkTagStop =
152
152
  * with the library's usual reduced-motion opt-outs (the lift's scale
153
153
  * and shadow stay — only their 0.12s ramp is dropped, so a
154
154
  * reduced-motion user keeps the state marker without the motion). The
155
- * lift scales ALONG each list's drag axis and never across it: the drop
156
- * slot is resolved from the items' live rects, so a span grown across
157
- * that axis would hide every sibling from the pointer and silently
158
- * collapse the gesture into a no-op;
155
+ * lift scales ALONG each list's drag axis by TASTE, not by necessity: a
156
+ * small scale that follows the gesture keeps the lifted item's own
157
+ * footprint tight in a dense chip row. The drop slot is resolved from
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;
159
164
  * - a key in `modelValue` that the catalog does not carry (a custom
160
165
  * tag, or an option deleted since) degrades to its raw key as the
161
166
  * tag label — the same rule as HkAffixPicker.tagEntries. Such a key
@@ -448,12 +453,22 @@ export const HkTagInput = defineComponent({
448
453
  /** The panel's keyboard reorder: move the ACTIVE row one slot inside
449
454
  * the selected group (the same edit a row drag makes, without a
450
455
  * pointer). Only a selected row has a place in that order, the ends of
451
- * the group are hard stops, and `false` means the key was not ours to
452
- * consume. The cursor stays on the key it was on — the stops rebuild
453
- * 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. */
454
459
  function nudgeActiveRow(delta: 1 | -1): boolean {
455
460
  const stop = activeStop.value;
456
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 resolves its landing slot by INDEX, so a reorder under the
465
+ // held pointer (or under a press that is about to become one) would
466
+ // leave the release moving whatever now sits at that index — a
467
+ // different row, silently. The chord is still consumed by the caller
468
+ // either way (Alt+Arrow is not a plain arrow, and the browser must
469
+ // not act on it); it just has nothing to move while a pointer owns
470
+ // the list.
471
+ if (chipDrag.pressed.value || rowDrag.pressed.value) return false;
457
472
  const group = selectedRows.value;
458
473
  const from = group.findIndex((option) => option.key === stop.option.key);
459
474
  if (from < 0) return false;