@celestia-island/hikari 0.43.3 → 0.43.4

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.3",
3
+ "version": "0.43.4",
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",
@@ -444,4 +444,33 @@ describe("HkAffixPicker", () => {
444
444
  // CSS/text half of the contract is pinned by the contract test.
445
445
  expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
446
446
  });
447
+
448
+ it("caps its popup surface with the tag editor's own measure", async () => {
449
+ const { container } = mountPicker({ mode: "multi", selected: ["cn"] });
450
+ await openPopup(container);
451
+
452
+ // The picker renders through HkMenu, which forwards a cap to the shared
453
+ // panel surface: the popup scrolls inside min(18rem, 45dvh) instead of
454
+ // stretching to the panel's 36rem ceiling — the same measure HkTagInput
455
+ // uses, so the two sibling chip pickers read consistently.
456
+ const popout = document.querySelector<HTMLElement>(".hk-select-popout");
457
+ expect(popout, "the popup renders as the shared popout").toBeTruthy();
458
+ expect(popout!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(
459
+ "min(18rem, 45dvh)",
460
+ );
461
+ // The cap is the SURFACE's: the popout carries the hook and no inline
462
+ // max-height of its own…
463
+ expect(popout!.style.maxHeight).toBe("");
464
+ // …and the picker still opts out of matching the chip's width.
465
+ expect(document.querySelector<HTMLElement>(".hk-select-popout-host")!.style.minWidth).toBe(
466
+ "",
467
+ );
468
+
469
+ // The popup's designed measure is untouched: the content wrapper stays a
470
+ // plain width container with no cap or scroll region of its own.
471
+ const content = document.querySelector<HTMLElement>(".hk-affix-scroll");
472
+ expect(content, "the content wrapper renders").toBeTruthy();
473
+ expect(content!.hasAttribute("style"), "no inline style on the content").toBe(false);
474
+ expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
475
+ });
447
476
  });
@@ -72,6 +72,11 @@ function isSubsequence(query: string, text: string): boolean {
72
72
  * as one. The row list deliberately carries no max-height/overflow; a
73
73
  * nested second scrollbar inside the same window is a contract
74
74
  * violation (2026-09-08 user report: the language sheet scrolled twice).
75
+ * The surface itself is capped at `min(18rem, 45dvh)` through HkMenu's
76
+ * `maxHeight` forwarding (the HkTagInput measure, so the family reads
77
+ * consistently), which bounds the one scroll region instead of letting it
78
+ * stretch to the shared panel's 36rem ceiling. The popup's own designed
79
+ * measure (13–19rem wide) is unchanged: the cap bounds HEIGHT only.
75
80
  *
76
81
  * With `tagValues`, the multi picker's TAG LIST switches to
77
82
  * value-driven primary text: a selected key renders its mapped value
@@ -368,6 +373,10 @@ export const HkAffixPicker = defineComponent({
368
373
  anchorRef={chipRef.value}
369
374
  placement={placement}
370
375
  matchAnchorWidth={false}
376
+ // The same cap the tag editor's catalog uses: the popup hugs its
377
+ // own designed measure instead of stretching to the shared
378
+ // panel's 36rem ceiling, and a long catalog scrolls inside it.
379
+ maxHeight="min(18rem, 45dvh)"
371
380
  title={title}
372
381
  >
373
382
  {{
@@ -345,6 +345,75 @@ describe("HkMenu on the HkSelectPanel surface", () => {
345
345
  });
346
346
  });
347
347
 
348
+ describe("HkMenu surface cap (maxHeight)", () => {
349
+ const CAP = "min(18rem, 45dvh)";
350
+
351
+ it("renders no cap at all when the prop is absent (the additive no-op)", async () => {
352
+ // Pinned at the DOM level rather than in source text: with no
353
+ // `maxHeight` the shared panel forwards nothing, so the popout
354
+ // publishes no hook and carries no inline max-height — every existing
355
+ // consumer (theme switchers, cascades, the menubar) is untouched.
356
+ mountMenu(ref(true));
357
+ await settle();
358
+ const popout = popouts()[0];
359
+ expect(popout.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
360
+ expect(popout.style.maxHeight).toBe("");
361
+ expect(popout.hasAttribute("style"), "no inline style at all").toBe(false);
362
+ });
363
+
364
+ it("publishes the cap on the root popout and on every cascade level", async () => {
365
+ mountMenu(ref(true), siblingItems, {}, { props: { maxHeight: CAP } });
366
+ await settle();
367
+ expect(popouts()[0].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
368
+
369
+ // A cascade level is its own scroll surface, so it is capped too.
370
+ rowByLabel("Branch One")!.click();
371
+ await settle();
372
+ expect(popouts().length).toBe(2);
373
+ expect(popouts()[1].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
374
+
375
+ // …and the cap follows the cascade to whichever level is open.
376
+ rowByLabel("Branch Two")!.click();
377
+ await settle();
378
+ expect(popouts().length).toBe(2);
379
+ expect(popouts()[1].textContent).toContain("Two A");
380
+ expect(popouts()[1].style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
381
+
382
+ // Height only: the cap never touches the popout's width contract.
383
+ expect(popoutHosts()[0].style.minWidth).toBe("");
384
+ });
385
+
386
+ it("caps the mobile sheet path — on the band that actually scrolls", async () => {
387
+ window.innerWidth = 390;
388
+ mountMenu(ref(true), siblingItems, {}, { props: { maxHeight: CAP } });
389
+ await settle();
390
+ expect(sheets().length).toBe(1);
391
+
392
+ // In sheet mode the element the panel caps (and the one that scrolls) is
393
+ // the list band inside the sheet, not the sheet panel itself.
394
+ const band = sheets()[0].querySelector<HTMLElement>(".hk-select-sheet-list");
395
+ expect(band, "the sheet list band renders").toBeTruthy();
396
+ expect(band!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
397
+
398
+ rowByLabel("Branch One")!.click();
399
+ await settle();
400
+ expect(sheets().length).toBe(2);
401
+ const subBand = sheets()[1].querySelector<HTMLElement>(".hk-select-sheet-list");
402
+ expect(subBand, "the pushed sheet's band renders").toBeTruthy();
403
+ expect(subBand!.style.getPropertyValue("--hk-select-panel-max-height")).toBe(CAP);
404
+ });
405
+
406
+ it("leaves the mobile sheet band uncapped when the prop is absent", async () => {
407
+ window.innerWidth = 390;
408
+ mountMenu(ref(true), siblingItems);
409
+ await settle();
410
+ const band = sheets()[0].querySelector<HTMLElement>(".hk-select-sheet-list");
411
+ expect(band, "the sheet list band renders").toBeTruthy();
412
+ expect(band!.style.getPropertyValue("--hk-select-panel-max-height")).toBe("");
413
+ expect(band!.hasAttribute("style"), "no inline style at all").toBe(false);
414
+ });
415
+ });
416
+
348
417
  describe("HkMenu check column (selectMode)", () => {
349
418
  const checkedItems: HkMenuItem[] = [
350
419
  { key: "a", label: "Alpha", checked: true },
@@ -142,6 +142,20 @@ export default defineComponent({
142
142
  * shrink to fit.
143
143
  */
144
144
  matchAnchorWidth: { type: Boolean, default: false },
145
+ /**
146
+ * Cap for the panel surface of EVERY level, as any CSS length
147
+ * (`min(18rem, 45dvh)`, `24rem`, …): it is forwarded to each
148
+ * HkSelectPanel this menu renders — the root desktop popout, every
149
+ * cascade level and each mobile sheet layer (every level is its own
150
+ * scroll surface, so each one is capped).
151
+ *
152
+ * Undefined (the default) forwards nothing at all, so the shared panel
153
+ * keeps its own ceilings and every existing consumer — theme switchers,
154
+ * cascades, the menubar, popups — renders exactly as before. See
155
+ * HkSelectPanel's own `maxHeight` for the value contract: any CSS
156
+ * length, applied on the `dvh`-capable branch, never sanitized.
157
+ */
158
+ maxHeight: { type: String, default: undefined },
145
159
  },
146
160
  emits: ["update:open", "select"],
147
161
  setup(props, { emit, slots }) {
@@ -549,6 +563,7 @@ export default defineComponent({
549
563
  placement={cfg.placement}
550
564
  offset={cfg.offset}
551
565
  matchAnchorWidth={cfg.match}
566
+ maxHeight={props.maxHeight}
552
567
  onUpdate:open={(v: boolean) => {
553
568
  if (!v) onRootCloseRequest();
554
569
  }}
@@ -576,6 +591,7 @@ export default defineComponent({
576
591
  title={branch?.label ?? props.title}
577
592
  placement="bottom-start"
578
593
  offset={0}
594
+ maxHeight={props.maxHeight}
579
595
  onUpdate:open={(v: boolean) => {
580
596
  if (!v) onSubCloseRequest(level);
581
597
  }}
@@ -598,6 +614,7 @@ export default defineComponent({
598
614
  open={props.open}
599
615
  anchorRef={props.anchorRef}
600
616
  title={props.title}
617
+ maxHeight={props.maxHeight}
601
618
  onUpdate:open={(v: boolean) => {
602
619
  if (!v) onRootCloseRequest();
603
620
  }}
@@ -616,6 +633,7 @@ export default defineComponent({
616
633
  // Sub-levels carry no title of their own — the parent item's
617
634
  // label names the sheet.
618
635
  title={entry.item.label}
636
+ maxHeight={props.maxHeight}
619
637
  onUpdate:open={(v: boolean) => {
620
638
  if (!v) onStackCloseRequest(i);
621
639
  }}
@@ -101,10 +101,14 @@
101
101
 
102
102
  /* A disabled field reorders nothing: the chips drop the grab affordance
103
103
  * (and the gesture reservation with it) — the box's own not-allowed
104
- * cursor takes over. */
104
+ * cursor takes over — and they become plain TEXT: with no drag left to
105
+ * lose, the press is free to select, so the chips are selectable here
106
+ * (the one state where selection and reordering cannot compete). */
105
107
  .hk-tag-input-box[data-disabled] .hk-tag-input-tag {
106
108
  cursor: default;
107
109
  touch-action: auto;
110
+ user-select: text;
111
+ -webkit-user-select: text;
108
112
  }
109
113
 
110
114
  /* ── inline typing input / placeholder ────────────────────────────── */
@@ -318,14 +322,50 @@
318
322
  }
319
323
 
320
324
  /* ── reordering ───────────────────────────────────────────────────── */
321
- /* Live drag (both lists): the item being moved dims, the slot it would
322
- * land in is ringed. Chips carry the state as classes (HkTag takes no
323
- * arbitrary attributes), panel rows as data attributes — both are
324
- * assertable hooks that never collide with the pointer/hover faces. */
325
+ /* Live drag (both lists): the item being moved LIFTS — a touch larger and
326
+ * raised off the surface — while the slot it would land in is ringed. The
327
+ * lift is what makes the drag read on touch, where the finger covers the
328
+ * chip, the cursor is invisible and dimming alone looks like nothing
329
+ * happened. The dimming stays LIGHT on purpose (0.85, the value of the
330
+ * HkDraggableList/HkDraggableGrid drag ghost): the item must still read as
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).
336
+ *
337
+ * Chips carry the state as classes (HkTag takes no arbitrary attributes),
338
+ * panel rows as data attributes — both are assertable hooks that never
339
+ * collide with the pointer/hover faces. */
325
340
  .hk-tag-input-tag-dragging,
326
341
  .hk-tag-input-row[data-dragging] {
327
- opacity: 0.55;
342
+ opacity: 0.85;
328
343
  cursor: grabbing;
344
+ box-shadow:
345
+ 0 6px 18px rgb(0 0 0 / 24%),
346
+ 0 2px 6px rgb(0 0 0 / 16%);
347
+ transition: transform 0.12s ease, box-shadow 0.12s ease;
348
+ }
349
+
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). */
363
+ .hk-tag-input-tag-dragging {
364
+ transform: scaleX(1.03);
365
+ }
366
+
367
+ .hk-tag-input-row[data-dragging] {
368
+ transform: scaleY(1.03);
329
369
  }
330
370
 
331
371
  .hk-tag-input-tag-drop {
@@ -384,7 +424,13 @@
384
424
  .hk-tag-input-box,
385
425
  .hk-tag-input-chevron,
386
426
  .hk-tag-input-grip,
387
- .hk-tag-input-row {
427
+ .hk-tag-input-row,
428
+ /* The lift itself is KEPT without its ramp: the scale and the raised
429
+ * shadow are a static state marker, not motion, and dropping them would
430
+ * take the drag feedback away from exactly the users who cannot rely on
431
+ * the cursor following the pointer — only the 0.12s transition goes. */
432
+ .hk-tag-input-tag-dragging,
433
+ .hk-tag-input-row[data-dragging] {
388
434
  transition: none;
389
435
  }
390
436
  }
@@ -1,4 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
2
5
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
6
 
4
7
  import HkModal from "./HkModal";
@@ -1494,6 +1497,92 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1494
1497
  expect(tagTexts(container)).toEqual(["gitee.com", "Technology", "News"]);
1495
1498
  });
1496
1499
 
1500
+ it("lifts the dragged chip and drops the lift on release", async () => {
1501
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1502
+ const strip = tags(container);
1503
+ pinStrip(strip, 60, "x");
1504
+
1505
+ // Mid-gesture (still held): exactly the item being moved carries the
1506
+ // drag state — the hook the stylesheet lifts — and the slot it would
1507
+ // land in keeps its own ring. Nothing else is marked.
1508
+ holdPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, strip[0]);
1509
+ await nextTick();
1510
+ const dragging = tags(container).filter((t) =>
1511
+ t.classList.contains("hk-tag-input-tag-dragging"),
1512
+ );
1513
+ expect(dragging).toHaveLength(1);
1514
+ expect(dragging[0]).toBe(strip[0]);
1515
+ expect(tags(container).filter((t) => t.classList.contains("hk-tag-input-tag-drop"))).toHaveLength(
1516
+ 1,
1517
+ );
1518
+
1519
+ releasePointer({ x: 200, y: 10 });
1520
+ await settle();
1521
+ // The lift is a LIVE-drag state: the release clears it in both lists.
1522
+ expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
1523
+ expect(container.querySelectorAll(".hk-tag-input-tag-drop")).toHaveLength(0);
1524
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1525
+ });
1526
+
1527
+ it("drops the chip's lift when the gesture is cancelled without a drop", async () => {
1528
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1529
+ pinStrip(tags(container), 60, "x");
1530
+
1531
+ holdPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, tags(container)[0]);
1532
+ await nextTick();
1533
+ expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(1);
1534
+
1535
+ // The browser takes the gesture back (a vertical scroll on touch): the
1536
+ // drag is abandoned — no reorder, and no lift left behind.
1537
+ window.dispatchEvent(
1538
+ new PointerEvent("pointercancel", { bubbles: true, pointerId: 1, pointerType: "touch" }),
1539
+ );
1540
+ await settle();
1541
+ expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
1542
+ expect(container.querySelectorAll(".hk-tag-input-tag-drop")).toHaveLength(0);
1543
+ expect(events.modelValue).toEqual([]);
1544
+ });
1545
+
1546
+ it("lifts the dragged panel row too, and drops the lift on release", async () => {
1547
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech", "de"] });
1548
+ await openPanel(container);
1549
+ const selected = reorderableRows();
1550
+ pinStrip(selected, 40, "y");
1551
+
1552
+ // The row face of the same lift is the data attribute the stylesheet
1553
+ // keys off (rows carry no classes of their own).
1554
+ holdPointer({ x: 10, y: 10 }, { x: 10, y: 130 }, grip(selected[0]));
1555
+ await nextTick();
1556
+ const draggingRows = rows().filter((row) => row.hasAttribute("data-dragging"));
1557
+ expect(draggingRows).toHaveLength(1);
1558
+ expect(draggingRows[0]).toBe(selected[0]);
1559
+ expect(rows().filter((row) => row.hasAttribute("data-drop"))).toHaveLength(1);
1560
+
1561
+ releasePointer({ x: 10, y: 130 });
1562
+ await settle();
1563
+ expect(rows().filter((row) => row.hasAttribute("data-dragging"))).toHaveLength(0);
1564
+ expect(rows().filter((row) => row.hasAttribute("data-drop"))).toHaveLength(0);
1565
+ expect(events.modelValue.at(-1)).toEqual(["tech", "de", "news"]);
1566
+ });
1567
+
1568
+ it("hands a disabled field's chips to text selection instead of a drag", async () => {
1569
+ const { events, container } = mountTagInput({ modelValue: ["news", "tech"], disabled: true });
1570
+
1571
+ // The stylesheet restores selection (and drops the grab cursor) through
1572
+ // the box's OWN disabled hook — happy-dom has no layout, so the pin is
1573
+ // on that attribute, which is exactly what the rule keys off.
1574
+ expect(field(container).hasAttribute("data-disabled")).toBe(true);
1575
+ expect(tags(container)).toHaveLength(2);
1576
+
1577
+ // Nothing can be reordered here, so a press that travels the whole strip
1578
+ // must not be spent on a drag: no state, no emission, no lifted chip.
1579
+ pinStrip(tags(container), 60, "x");
1580
+ dragPointer({ x: 10, y: 10 }, { x: 200, y: 10 }, tags(container)[0]);
1581
+ await settle();
1582
+ expect(events.modelValue).toEqual([]);
1583
+ expect(container.querySelectorAll(".hk-tag-input-tag-dragging")).toHaveLength(0);
1584
+ });
1585
+
1497
1586
  it("keeps a sub-threshold press a click — and never drags from the ×", async () => {
1498
1587
  const { events, container } = mountTagInput({ modelValue: ["news", "tech"] });
1499
1588
  pinStrip(tags(container), 60, "x");
@@ -1924,3 +2013,97 @@ describe("HkTagInput panel geometry and drag reordering", () => {
1924
2013
  expect(tagTexts(container)).toEqual(["Technology"]);
1925
2014
  });
1926
2015
  });
2016
+
2017
+ /* The stylesheet half of the states asserted above: happy-dom has no layout
2018
+ * engine, so the DOM tests can only pin the HOOKS — these assertions pin
2019
+ * what those hooks actually buy (the lift, the selection restore, and the
2020
+ * reduced-motion handling), the way the sibling contract tests in this
2021
+ * directory pin the scroll and panel-geometry contracts. */
2022
+ const scss = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "HkTagInput.scss"), "utf-8");
2023
+
2024
+ /** The declaration block of the rule whose selector list starts at the
2025
+ * FIRST `selector` occurrence — braces balanced, so a nested block cannot
2026
+ * let properties appended after it evade the pin. */
2027
+ function styleBlock(selector: string): string {
2028
+ const at = scss.indexOf(selector);
2029
+ expect(at, `${selector} is styled`).toBeGreaterThanOrEqual(0);
2030
+ const open = scss.indexOf("{", at);
2031
+ let depth = 0;
2032
+ for (let i = open; i < scss.length; i += 1) {
2033
+ if (scss[i] === "{") depth += 1;
2034
+ else if (scss[i] === "}") {
2035
+ depth -= 1;
2036
+ if (depth === 0) return scss.slice(open, i + 1);
2037
+ }
2038
+ }
2039
+ return "";
2040
+ }
2041
+
2042
+ describe("HkTagInput drag feedback and text selection", () => {
2043
+ it("lifts the dragged item in both lists instead of only dimming it", () => {
2044
+ const lift = styleBlock(".hk-tag-input-tag-dragging,");
2045
+ // ONE rule body serves both lists: the chip's class and the row's data
2046
+ // attribute — the very hooks the DOM tests above assert.
2047
+ expect(scss).toMatch(
2048
+ /\.hk-tag-input-tag-dragging,\s*\n\.hk-tag-input-row\[data-dragging\]\s*\{/,
2049
+ );
2050
+ expect(lift).toMatch(/box-shadow:/);
2051
+ // Subtle dimming on purpose: the lifted item must stay readable as the
2052
+ // one being moved, not fade away (the pre-wave 0.55 did).
2053
+ expect(lift).toMatch(/opacity:\s*0\.85/);
2054
+ // The lift is a state, never layout: no width/height/margin edits, and
2055
+ // no isotropic scale smuggled into the shared body.
2056
+ expect(lift).not.toMatch(/(^|[^-])(width|height|margin|padding)\s*:/);
2057
+ expect(lift).not.toMatch(/transform\s*:/);
2058
+ });
2059
+
2060
+ it("scales each list ALONG its drag axis and never across it", () => {
2061
+ // 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.
2070
+ const axisTransform = (hook: string): string | undefined =>
2071
+ scss.match(
2072
+ new RegExp(`${hook.replace(/[[\]]/g, "\\$&")}\\s*\\{\\s*transform:\\s*([^;]+);`),
2073
+ )?.[1];
2074
+ expect(axisTransform(".hk-tag-input-tag-dragging"), "the chip strip is horizontal").toBe(
2075
+ "scaleX(1.03)",
2076
+ );
2077
+ expect(axisTransform(".hk-tag-input-row[data-dragging]"), "the row list is vertical").toBe(
2078
+ "scaleY(1.03)",
2079
+ );
2080
+ });
2081
+
2082
+ it("keeps the lift's state marker under reduced motion and drops only its ramp", () => {
2083
+ const reduce = scss.slice(scss.indexOf("@media (prefers-reduced-motion: reduce)"));
2084
+ expect(reduce, "the reduced-motion block exists").toContain(".hk-tag-input-tag-dragging");
2085
+ expect(reduce).toContain(".hk-tag-input-row[data-dragging]");
2086
+ expect(reduce).toMatch(/transition:\s*none/);
2087
+ // The scale and the shadow survive — a reduced-motion user gets the
2088
+ // same affordance, just without the 0.12s motion.
2089
+ expect(reduce).not.toMatch(/transform:\s*none/);
2090
+ expect(reduce).not.toMatch(/box-shadow:\s*none/);
2091
+ });
2092
+
2093
+ it("restores text selection on a disabled field's chips only", () => {
2094
+ const disabled = styleBlock(".hk-tag-input-box[data-disabled] .hk-tag-input-tag");
2095
+ expect(disabled).toContain("user-select: text");
2096
+ expect(disabled).toContain("-webkit-user-select: text");
2097
+ expect(disabled).toContain("cursor: default");
2098
+ // The editable chip keeps the gesture reservation: its press is the
2099
+ // reorder gesture, so it stays non-selectable (the documented trade-off).
2100
+ const chip = styleBlock(".hk-tag-input-tag {");
2101
+ expect(chip).toContain("user-select: none");
2102
+ expect(chip).toContain("cursor: grab");
2103
+ // The panel rows' LABELS are never selection-blocked — only the grip,
2104
+ // which is the drag handle, reserves the gesture.
2105
+ expect(styleBlock(".hk-tag-input-grip {")).toContain("user-select: none");
2106
+ const row = styleBlock(".hk-tag-input-row {");
2107
+ expect(row).not.toMatch(/user-select/);
2108
+ });
2109
+ });
@@ -130,8 +130,11 @@ type HkTagStop =
130
130
  * a plain click/tap still clicks:
131
131
  * * a CHIP is dragged by its BODY (its × never starts a drag, and
132
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;
133
+ * chip follows no ghost — it LIFTS in place (a touch larger,
134
+ * raised off the surface, only lightly dimmed so it stays legible
135
+ * as the item being moved) while the drop slot is ringed and the
136
+ * release emits the reordered array. The lift is what makes the
137
+ * gesture readable under a finger, where the cursor is invisible;
135
138
  * * in the PANEL only the SELECTED rows move, and only through the
136
139
  * explicit GRIP HANDLE at the row's trailing edge (the handle is
137
140
  * the one element that claims touch, so the sheet keeps scrolling
@@ -142,10 +145,17 @@ type HkTagStop =
142
145
  * of the (capped) surface in one gesture; nothing is scrolled
143
146
  * without a live drag. A row drag is CLAMPED to the selected
144
147
  * group: unselected rows are not drop targets and never move, and
145
- * a release past the group's end lands on its last position;
148
+ * a release past the group's end lands on its last position. A
149
+ * dragged row lifts exactly like a dragged chip;
146
150
  * both lists are keyed HkListTransition groups (FLIP `move`), so the
147
151
  * moved chip/row and its neighbours animate into their new places,
148
- * with the library's usual reduced-motion opt-outs;
152
+ * with the library's usual reduced-motion opt-outs (the lift's scale
153
+ * and shadow stay — only their 0.12s ramp is dropped, so a
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;
149
159
  * - a key in `modelValue` that the catalog does not carry (a custom
150
160
  * tag, or an option deleted since) degrades to its raw key as the
151
161
  * tag label — the same rule as HkAffixPicker.tagEntries. Such a key
@@ -188,6 +198,21 @@ type HkTagStop =
188
198
  * row). The grip handle is a pointer affordance; the field and the panel
189
199
  * are otherwise pointer-only for reordering.
190
200
  *
201
+ * CHIP TEXT IS NOT SELECTABLE WHILE THE FIELD IS EDITABLE — the chip is an
202
+ * interactive control, and its press is the reorder gesture, exactly as a
203
+ * `<button>`'s label is not selectable because its press is the activation.
204
+ * The two cannot be had at once: a press allowed to start a text selection
205
+ * is a press the browser has already claimed, so it can no longer become a
206
+ * ~6px drag (on touch the long press raises the selection callout and the
207
+ * drag dies on `pointercancel`; on a mouse the two gestures run together
208
+ * and neither reads). The threshold therefore wins by design, and the
209
+ * trade-off is paid where it costs nothing instead: a DISABLED field
210
+ * reorders nothing, so its chips ARE selectable text — the stylesheet
211
+ * flips their `user-select` back to `text` (and drops the grab cursor)
212
+ * under the box's own disabled hook. The panel rows keep their LABELS
213
+ * selectable even while editable: only the grip handle reserves the
214
+ * gesture there, and it — not the label — is the drag target.
215
+ *
191
216
  * `maxTags` freezes ADDS only. Reordering stays available at the cap:
192
217
  * the order is host state, and a reorder adds nothing (nor removes
193
218
  * anything).