@celestia-island/hikari 0.43.2 → 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 +1 -1
- package/src/components/HkAffixPicker.test.tsx +29 -0
- package/src/components/HkAffixPicker.tsx +9 -0
- package/src/components/HkMenu.test.ts +69 -0
- package/src/components/HkMenu.tsx +18 -0
- package/src/components/HkTagInput.scss +53 -7
- package/src/components/HkTagInput.test.tsx +183 -0
- package/src/components/HkTagInput.tsx +29 -4
- package/src/components/HkThemeToggle.contract.test.ts +16 -1
- package/src/components/HkThemeToggle.scss +16 -9
package/package.json
CHANGED
|
@@ -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
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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.
|
|
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 —
|
|
134
|
-
*
|
|
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).
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* layout engine, so the geometry contract is pinned as an scss-text
|
|
7
7
|
* assertion: the theme-row lead cell must carry an explicit size that
|
|
8
8
|
* fits the widest host lead mark, not just the mixin's generic box.
|
|
9
|
+
*
|
|
10
|
+
* The widened cell must stay scoped to the lead slot (2026-09-12 field
|
|
11
|
+
* report): shipped first as a bare `.s-theme-item-btn .hk-menu-item-icon`
|
|
12
|
+
* rule, it also matched the 自定义 row and every host row reusing
|
|
13
|
+
* s-theme-item-btn (chest's mode-extra DPI entry) — and mi.icon
|
|
14
|
+
* normalizes those cells' svg to the cell, so their 14px glyphs rendered
|
|
15
|
+
* 28px wide.
|
|
9
16
|
*/
|
|
10
17
|
import { describe, expect, it } from "vitest";
|
|
11
18
|
import { readFileSync } from "node:fs";
|
|
@@ -19,10 +26,18 @@ describe("HkThemeToggle row lead-cell contract", () => {
|
|
|
19
26
|
it("sizes the theme-row lead cell to fit a 28px swatch mark", () => {
|
|
20
27
|
const css = read("HkThemeToggle.scss");
|
|
21
28
|
const block = css.match(
|
|
22
|
-
/\.s-theme-item-btn \.hk-menu-item-icon\s*\{[^}]*\}/,
|
|
29
|
+
/\.s-theme-item-btn \.hk-menu-item-icon\.s-theme-item-lead\s*\{[^}]*\}/,
|
|
23
30
|
);
|
|
24
31
|
expect(block).not.toBeNull();
|
|
25
32
|
expect(block![0]).toContain("width: 28px");
|
|
26
33
|
expect(block![0]).toContain("height: 28px");
|
|
27
34
|
});
|
|
35
|
+
|
|
36
|
+
it("does not widen icon cells outside the leading slot", () => {
|
|
37
|
+
const css = read("HkThemeToggle.scss");
|
|
38
|
+
// A bare `.s-theme-item-btn .hk-menu-item-icon {` rule also hits the
|
|
39
|
+
// customize row and host mode-extra rows; the widened cell may only
|
|
40
|
+
// target the lead slot class.
|
|
41
|
+
expect(css).not.toMatch(/\.s-theme-item-btn \.hk-menu-item-icon\s*\{/);
|
|
42
|
+
});
|
|
28
43
|
});
|
|
@@ -188,15 +188,22 @@
|
|
|
188
188
|
@include mi.label;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
/* Leading
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
|
|
191
|
+
/* Leading slot glyphs share the spec's fixed icon cell — sized for the
|
|
192
|
+
* WIDEST lead mark hosts render there, not the generic 16px box: chest's
|
|
193
|
+
* three-dot palette swatch is 28px (3×8px dots + 2px gaps), and a mark
|
|
194
|
+
* wider than its cell bleeds symmetrically out of the cell into the row
|
|
195
|
+
* gap — at fractional zoom rounding the dots visually overlap the name
|
|
196
|
+
* (2026-09-11 field report). A uniform 28px cell keeps every row's name
|
|
197
|
+
* starting at the same x (swatch rows and icon rows stay aligned) and
|
|
198
|
+
* leaves the bleed zero room at any zoom.
|
|
199
|
+
*
|
|
200
|
+
* The widened cell is scoped to the LEAD slot only (2026-09-12 field
|
|
201
|
+
* report): the bare `.s-theme-item-btn .hk-menu-item-icon` this shipped
|
|
202
|
+
* as also matched the 自定义 row and every host row reusing
|
|
203
|
+
* s-theme-item-btn (chest's mode-extra DPI entry), where mi.icon
|
|
204
|
+
* normalizes the svg to the cell — their 14px glyphs rendered 28px
|
|
205
|
+
* wide. Rows without a leading slot keep the standard menu metrics. */
|
|
206
|
+
.s-theme-item-btn .hk-menu-item-icon.s-theme-item-lead {
|
|
200
207
|
@include mi.icon;
|
|
201
208
|
|
|
202
209
|
width: 28px;
|