@celestia-island/hikari 0.43.0 → 0.43.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/DemoApp.tsx +22 -0
- package/src/components/HkSelect.scss +34 -6
- package/src/components/HkSelectPanel.test.tsx +88 -0
- package/src/components/HkSelectPanel.tsx +65 -10
- package/src/components/HkTagInput.scss +84 -1
- package/src/components/HkTagInput.singleScroll.contract.test.ts +78 -5
- package/src/components/HkTagInput.test.tsx +813 -9
- package/src/components/HkTagInput.tsx +459 -109
- package/src/composables/usePointerReorder.test.ts +651 -0
- package/src/composables/usePointerReorder.ts +432 -0
- package/src/i18n/locales/ar/components.json +2 -1
- package/src/i18n/locales/de/components.json +2 -1
- package/src/i18n/locales/en/components.json +2 -1
- package/src/i18n/locales/es/components.json +2 -1
- package/src/i18n/locales/fr/components.json +2 -1
- package/src/i18n/locales/ja/components.json +2 -1
- package/src/i18n/locales/ko/components.json +2 -1
- package/src/i18n/locales/pt/components.json +2 -1
- package/src/i18n/locales/ru/components.json +2 -1
- package/src/i18n/locales/zh-Hans/components.json +2 -1
- package/src/i18n/locales/zh-Hant/components.json +2 -1
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -150,6 +150,8 @@ export default defineComponent({
|
|
|
150
150
|
{ key: "c", label: "Archived" },
|
|
151
151
|
],
|
|
152
152
|
});
|
|
153
|
+
const centerAnchor = ref<HTMLElement | null>(null);
|
|
154
|
+
const centerPanelOpen = ref(false);
|
|
153
155
|
function togglePanelOpt(key: string, v: boolean) {
|
|
154
156
|
panel.value.checked = v
|
|
155
157
|
? [...panel.value.checked, key]
|
|
@@ -417,6 +419,26 @@ export default defineComponent({
|
|
|
417
419
|
>{opt.label}</HCheckbox>
|
|
418
420
|
))}
|
|
419
421
|
</HSelectPanel>
|
|
422
|
+
<span style="align-self:center;color:var(--color-muted, gray)">center placement:</span>
|
|
423
|
+
<span ref={centerAnchor} style="display:inline-flex">
|
|
424
|
+
<HButton onClick={() => (centerPanelOpen.value = !centerPanelOpen.value)}>Engine {centerPanelOpen.value ? <ChevronUp size={14} /> : <ChevronDown size={14} />}</HButton>
|
|
425
|
+
</span>
|
|
426
|
+
<HSelectPanel
|
|
427
|
+
open={centerPanelOpen.value}
|
|
428
|
+
onUpdate:open={(v: boolean) => (centerPanelOpen.value = v)}
|
|
429
|
+
anchorRef={centerAnchor.value ?? null}
|
|
430
|
+
title="Engine"
|
|
431
|
+
placement="top-center"
|
|
432
|
+
matchAnchorWidth={false}
|
|
433
|
+
>
|
|
434
|
+
{panel.value.opts.map((opt) => (
|
|
435
|
+
<HCheckbox
|
|
436
|
+
key={opt.key}
|
|
437
|
+
modelValue={panel.value.checked.includes(opt.key)}
|
|
438
|
+
onUpdate:modelValue={(v: boolean) => togglePanelOpt(opt.key, v)}
|
|
439
|
+
>{opt.label}</HCheckbox>
|
|
440
|
+
))}
|
|
441
|
+
</HSelectPanel>
|
|
420
442
|
</div>
|
|
421
443
|
</section>
|
|
422
444
|
|
|
@@ -106,14 +106,16 @@
|
|
|
106
106
|
position: fixed;
|
|
107
107
|
min-width: 180px;
|
|
108
108
|
|
|
109
|
-
/* Pop motion family — the popout grows out of the anchor edge
|
|
110
|
-
* on (data-side/data-align
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
* with one curve. */
|
|
109
|
+
/* Pop motion family — the popout grows out of the anchor edge (or
|
|
110
|
+
* midpoint, for the -center placements) it sits on (data-side/data-align
|
|
111
|
+
* are set by HkSelectPanel from the RESOLVED placement, including after
|
|
112
|
+
* an auto-flip) and retracts back into it. Same tokens as
|
|
113
|
+
* HkPopover/HkTooltip so every anchored surface moves with one curve. */
|
|
114
114
|
&[data-side="bottom"] { transform-origin: top left; }
|
|
115
|
+
&[data-side="bottom"][data-align="center"] { transform-origin: top center; }
|
|
115
116
|
&[data-side="bottom"][data-align="end"] { transform-origin: top right; }
|
|
116
117
|
&[data-side="top"] { transform-origin: bottom left; }
|
|
118
|
+
&[data-side="top"][data-align="center"] { transform-origin: bottom center; }
|
|
117
119
|
&[data-side="top"][data-align="end"] { transform-origin: bottom right; }
|
|
118
120
|
}
|
|
119
121
|
|
|
@@ -175,9 +177,24 @@
|
|
|
175
177
|
/* Menu/switcher panels overflow a bare 240px cap even on tall viewports
|
|
176
178
|
(user feedback 2026-08-30: identity header + rows ≈ 320px+). Keep 240px
|
|
177
179
|
as the floor for select lists on short viewports, otherwise let the
|
|
178
|
-
panel size to its content up to a viewport-relative ceiling
|
|
180
|
+
panel size to its content up to a viewport-relative ceiling — plain-vh
|
|
181
|
+
first, dvh second, so an engine without dvh drops the dvh twin at PARSE
|
|
182
|
+
time and keeps the vh cap (the HkModal/HkDrawer/HkImageLightbox rule).
|
|
183
|
+
A consumer that needs a tighter surface (HkTagInput's catalog) publishes
|
|
184
|
+
--hk-select-panel-max-height through HkSelectPanel's `maxHeight` prop;
|
|
185
|
+
that hook rides the dvh-capable branch below, because a dvh value
|
|
186
|
+
reaching a dvh-less engine through var() would not be parse-dropped but
|
|
187
|
+
invalid at COMPUTED-value time — which resolves to `max-height: none`,
|
|
188
|
+
losing the cap entirely. Such an engine keeps the plain-vh ceiling
|
|
189
|
+
instead, exactly as it does today. */
|
|
179
190
|
max-height: max(240px, min(36rem, calc(100vh - 32px)));
|
|
180
191
|
max-height: max(240px, min(36rem, calc(100dvh - 32px)));
|
|
192
|
+
@supports (height: 1dvh) {
|
|
193
|
+
max-height: var(
|
|
194
|
+
--hk-select-panel-max-height,
|
|
195
|
+
max(240px, min(36rem, calc(100dvh - 32px)))
|
|
196
|
+
);
|
|
197
|
+
}
|
|
181
198
|
display: flex;
|
|
182
199
|
flex-direction: column;
|
|
183
200
|
gap: var(--space-2);
|
|
@@ -369,6 +386,17 @@
|
|
|
369
386
|
min-height: 0;
|
|
370
387
|
overflow-y: auto;
|
|
371
388
|
overscroll-behavior: contain;
|
|
389
|
+
/* The same consumer-tunable cap the popout reads, applied to the band
|
|
390
|
+
that actually scrolls: the sheet header keeps its place and the
|
|
391
|
+
catalog scrolls inside it. `none` (the fallback) is exactly today's
|
|
392
|
+
behaviour, so an untouched consumer's sheet is unchanged.
|
|
393
|
+
DELIBERATELY not wrapped in the popout's `@supports (height: 1dvh)`
|
|
394
|
+
guard: this band's fallback already IS the property's initial value,
|
|
395
|
+
so a value the engine cannot use resolves to the same `none` the
|
|
396
|
+
guard would produce — the asymmetry is a no-op, not an oversight.
|
|
397
|
+
The sheet PANEL above keeps its own plain-vh/dvh family cap either
|
|
398
|
+
way, so a hidden cap can never leave the sheet unbounded. */
|
|
399
|
+
max-height: var(--hk-select-panel-max-height, none);
|
|
372
400
|
/* Side insets so the option blocks (full-row hover pills) read as
|
|
373
401
|
cards inside the sheet instead of an edge-to-edge strip glued to
|
|
374
402
|
both screen sides — matching the popover family's breathing room.
|
|
@@ -301,6 +301,94 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
301
301
|
expect(flipped).toBeGreaterThan(8);
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
+
it("balances a top-center popout on the anchor's horizontal midpoint", async () => {
|
|
305
|
+
// happy-dom lays nothing out, so the panel measures at the
|
|
306
|
+
// max(anchor, 180) fallback width — anchor spanning 200..360 (midpoint
|
|
307
|
+
// 280, fallback width 180) puts the balanced left edge at
|
|
308
|
+
// 280 - 180/2 = 190.
|
|
309
|
+
const { container, open } = mountPanel({ placement: "top-center" });
|
|
310
|
+
await nextTick();
|
|
311
|
+
|
|
312
|
+
const anchor = container.querySelector<HTMLButtonElement>("button")!;
|
|
313
|
+
anchor.getBoundingClientRect = () =>
|
|
314
|
+
({ top: 500, bottom: 520, left: 200, right: 360, width: 160, height: 20 }) as DOMRect;
|
|
315
|
+
open.value = true;
|
|
316
|
+
await nextTick();
|
|
317
|
+
|
|
318
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
319
|
+
expect(host.style.left).toBe("190px");
|
|
320
|
+
// Top-side still honored (anchor bottom 520 → panel above it), and the
|
|
321
|
+
// resolved alignment drives the pop motion's transform origin.
|
|
322
|
+
expect(Number.parseInt(host.style.top, 10)).toBeLessThan(520);
|
|
323
|
+
expect(host.dataset.align).toBe("center");
|
|
324
|
+
expect(host.dataset.side).toBe("top");
|
|
325
|
+
|
|
326
|
+
// A centered panel poking past the left edge clamps to the viewport
|
|
327
|
+
// pad instead of mirroring the overflow to both sides: anchor
|
|
328
|
+
// 0..40 → raw left (20 - 180/2) = -70 → clamped to 8.
|
|
329
|
+
anchor.getBoundingClientRect = () =>
|
|
330
|
+
({ top: 500, bottom: 520, left: 0, right: 40, width: 40, height: 20 }) as DOMRect;
|
|
331
|
+
window.dispatchEvent(new Event("resize"));
|
|
332
|
+
await nextTick();
|
|
333
|
+
expect(host.style.left).toBe("8px");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("reports center alignment through an auto-flip so the pop origin follows", async () => {
|
|
337
|
+
// mountPanel's rig anchor sits at the very top of the test layout, so
|
|
338
|
+
// a top-center panel cannot fit above and flips bottom-side — the
|
|
339
|
+
// flip must carry data-side/data-align with it (transform origin).
|
|
340
|
+
const { container, open } = mountPanel({ placement: "top-center" });
|
|
341
|
+
await nextTick();
|
|
342
|
+
open.value = true;
|
|
343
|
+
await nextTick();
|
|
344
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
345
|
+
await nextTick();
|
|
346
|
+
|
|
347
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
348
|
+
expect(host.dataset.side).toBe("bottom");
|
|
349
|
+
expect(host.dataset.align).toBe("center");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("opens an unflipped bottom-center popout below the anchor", async () => {
|
|
353
|
+
// The bottom-center twin: same balance math, opposite side, and no
|
|
354
|
+
// flip here (the faked anchor sits low with room below).
|
|
355
|
+
const { container, open } = mountPanel({ placement: "bottom-center" });
|
|
356
|
+
await nextTick();
|
|
357
|
+
|
|
358
|
+
const anchor = container.querySelector<HTMLButtonElement>("button")!;
|
|
359
|
+
anchor.getBoundingClientRect = () =>
|
|
360
|
+
({ top: 100, bottom: 120, left: 200, right: 360, width: 160, height: 20 }) as DOMRect;
|
|
361
|
+
open.value = true;
|
|
362
|
+
await nextTick();
|
|
363
|
+
|
|
364
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
365
|
+
expect(host.dataset.side).toBe("bottom");
|
|
366
|
+
expect(host.dataset.align).toBe("center");
|
|
367
|
+
// Anchor top 100 + offset 4 → the panel hangs below 104.
|
|
368
|
+
expect(Number.parseInt(host.style.top, 10)).toBeGreaterThan(120);
|
|
369
|
+
expect(host.style.left).toBe("190px");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("clamps a centered popout at the right viewport pad near the right edge", async () => {
|
|
373
|
+
// Anchor hugging the right edge: raw balanced left 1100 + (80 - 180)/2
|
|
374
|
+
// = 1050 would poke past 1200 - 8 - 180 = 1012 → pinned at the pad.
|
|
375
|
+
// The expected left edge is viewport arithmetic, so pin the width here
|
|
376
|
+
// (happy-dom's bare default is 1024 — the afterEach reset only helps
|
|
377
|
+
// full-file runs, not -t filtering; afterEach re-asserts 1200 anyway).
|
|
378
|
+
setViewport(1200);
|
|
379
|
+
const { container, open } = mountPanel({ placement: "bottom-center" });
|
|
380
|
+
await nextTick();
|
|
381
|
+
|
|
382
|
+
const anchor = container.querySelector<HTMLButtonElement>("button")!;
|
|
383
|
+
anchor.getBoundingClientRect = () =>
|
|
384
|
+
({ top: 100, bottom: 120, left: 1100, right: 1180, width: 80, height: 20 }) as DOMRect;
|
|
385
|
+
open.value = true;
|
|
386
|
+
await nextTick();
|
|
387
|
+
|
|
388
|
+
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
389
|
+
expect(host.style.left).toBe("1012px");
|
|
390
|
+
});
|
|
391
|
+
|
|
304
392
|
it("clamps a tall flipped popout into the viewport instead of going negative", async () => {
|
|
305
393
|
// The taller viewport-relative CSS cap admits ~576px panels: anchored
|
|
306
394
|
// mid-viewport (top 380 / bottom 424 on an 800px-tall viewport), a
|
|
@@ -47,6 +47,15 @@ import "./HkSelect.scss";
|
|
|
47
47
|
* events on the panel surface are forwarded (`keydown`) so owners that own
|
|
48
48
|
* an option model (like HkSelect) can run their own arrow/enter navigation.
|
|
49
49
|
*
|
|
50
|
+
* Geometry is the surface's to decide: `matchAnchorWidth` (default true,
|
|
51
|
+
* select parity) ties the popout's minimum width to the trigger, and the
|
|
52
|
+
* optional `maxHeight` caps whichever element scrolls (the desktop popout,
|
|
53
|
+
* the mobile sheet's list band). Both are opt-outs — a consumer that passes
|
|
54
|
+
* neither gets the historic panel, which is what every existing caller
|
|
55
|
+
* does; a consumer whose content should scroll inside a hugged surface
|
|
56
|
+
* (a tag catalog, a long filter list) sets them and relies on the panel's
|
|
57
|
+
* ONE scrollbar.
|
|
58
|
+
*
|
|
50
59
|
* Mobile sheets also run a duplicate-title filter: content that opens with
|
|
51
60
|
* a non-interactive heading exactly repeating the panel `title` is hidden
|
|
52
61
|
* (`.hk-sheet-dup-title`) — the sheet header already names the sheet, and
|
|
@@ -56,8 +65,10 @@ import "./HkSelect.scss";
|
|
|
56
65
|
|
|
57
66
|
export type SelectPanelPlacement =
|
|
58
67
|
| "bottom-start"
|
|
68
|
+
| "bottom-center"
|
|
59
69
|
| "bottom-end"
|
|
60
70
|
| "top-start"
|
|
71
|
+
| "top-center"
|
|
61
72
|
| "top-end";
|
|
62
73
|
|
|
63
74
|
const VIEWPORT_PAD = 8;
|
|
@@ -71,7 +82,10 @@ export default defineComponent({
|
|
|
71
82
|
anchorRef: { type: Object as PropType<HTMLElement | null>, default: null },
|
|
72
83
|
/** Sheet header on mobile / a11y name for the panel. */
|
|
73
84
|
title: { type: String, default: "" },
|
|
74
|
-
/** Base placement of the popout relative to the anchor (auto-flips).
|
|
85
|
+
/** Base placement of the popout relative to the anchor (auto-flips).
|
|
86
|
+
* The `-center` variants center the popout on the anchor's horizontal
|
|
87
|
+
* midpoint (footer-center triggers whose menus would otherwise read
|
|
88
|
+
* as lopsided off a narrow pill). */
|
|
75
89
|
placement: {
|
|
76
90
|
type: String as PropType<SelectPanelPlacement>,
|
|
77
91
|
default: "bottom-start",
|
|
@@ -80,6 +94,23 @@ export default defineComponent({
|
|
|
80
94
|
offset: { type: Number, default: 4 },
|
|
81
95
|
/** Popout min-width follows the anchor width (select parity). */
|
|
82
96
|
matchAnchorWidth: { type: Boolean, default: true },
|
|
97
|
+
/** Cap for the surface that scrolls — the desktop popout or the
|
|
98
|
+
* mobile sheet's list band — as ANY CSS length (`min(18rem, 45dvh)`,
|
|
99
|
+
* `24rem`, `calc(50vh - 3rem)`).
|
|
100
|
+
*
|
|
101
|
+
* Undefined (the default) keeps the stylesheet's own ceilings, so
|
|
102
|
+
* every existing consumer is untouched; a consumer whose content
|
|
103
|
+
* should scroll instead of stretching the panel to the viewport sets
|
|
104
|
+
* it, and the panel's ONE scrollbar (never a second region) does the
|
|
105
|
+
* rest. Applied through the `--hk-select-panel-max-height` hook
|
|
106
|
+
* HkSelect.scss reads, inside its `@supports (height: 1dvh)` branch —
|
|
107
|
+
* a value an engine cannot use is therefore IGNORED rather than
|
|
108
|
+
* clamped: the popout keeps the plain-vh ceiling it has today.
|
|
109
|
+
* A MALFORMED value (a typo, a bare number) is likewise not sanitized:
|
|
110
|
+
* the substituted declaration becomes invalid at computed-value time
|
|
111
|
+
* and resolves to `max-height: none`, i.e. the surface is UNCAPPED —
|
|
112
|
+
* the type gate is a CSS length, the value gate is the consumer's. */
|
|
113
|
+
maxHeight: { type: String, default: undefined },
|
|
83
114
|
/** Dock as a bottom sheet on phone-width viewports. */
|
|
84
115
|
sheetOnMobile: { type: Boolean, default: true },
|
|
85
116
|
},
|
|
@@ -315,6 +346,14 @@ export default defineComponent({
|
|
|
315
346
|
const sheetBodyRef = ref<HTMLElement>();
|
|
316
347
|
const coords = ref<{ top?: string; left?: string; minWidth?: string }>({});
|
|
317
348
|
|
|
349
|
+
/** Host-tunable surface cap (the `maxHeight` prop): published as the
|
|
350
|
+
* custom property HkSelect.scss reads on whichever element scrolls,
|
|
351
|
+
* so the stylesheet keeps today's ceiling as its fallback and a
|
|
352
|
+
* consumer that passes nothing renders exactly as before. */
|
|
353
|
+
const surfaceCap = computed<Record<string, string> | undefined>(() =>
|
|
354
|
+
props.maxHeight ? { "--hk-select-panel-max-height": props.maxHeight } : undefined,
|
|
355
|
+
);
|
|
356
|
+
|
|
318
357
|
// ── overlay scrollbar (shared chrome) ─────────────────────────
|
|
319
358
|
// Attached per open on whichever surface scrolls — the desktop
|
|
320
359
|
// popout or the mobile sheet list — and detached on close/unmount
|
|
@@ -474,9 +513,10 @@ export default defineComponent({
|
|
|
474
513
|
// again after the DOM settles, when the panel's real box is known and
|
|
475
514
|
// flip/clamp decisions can use actual numbers.
|
|
476
515
|
/** Resolved popout orientation — drives the pop transition's
|
|
477
|
-
* transform-origin (grow from the anchor edge
|
|
478
|
-
* the host). Updated by positionPanel on every
|
|
479
|
-
|
|
516
|
+
* transform-origin (grow from the anchor edge or midpoint,
|
|
517
|
+
* data-side/align on the host). Updated by positionPanel on every
|
|
518
|
+
* reposition. */
|
|
519
|
+
const resolved = ref<{ side: "top" | "bottom"; align: "start" | "center" | "end" }>({
|
|
480
520
|
side: "bottom",
|
|
481
521
|
align: "start",
|
|
482
522
|
});
|
|
@@ -501,10 +541,12 @@ export default defineComponent({
|
|
|
501
541
|
side = "bottom";
|
|
502
542
|
top = r.bottom + props.offset;
|
|
503
543
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
544
|
+
const align = props.placement.endsWith("-center")
|
|
545
|
+
? "center"
|
|
546
|
+
: props.placement.endsWith("-end")
|
|
547
|
+
? "end"
|
|
548
|
+
: "start";
|
|
549
|
+
resolved.value = { side, align };
|
|
508
550
|
// One flip never re-checks: taller menu panels (the viewport-relative
|
|
509
551
|
// CSS cap) made this band reachable — a mid-viewport anchor flips
|
|
510
552
|
// bottom→top into a negative top that was applied verbatim. Clamp so
|
|
@@ -512,7 +554,15 @@ export default defineComponent({
|
|
|
512
554
|
// the panel's own internal scroll takes over.
|
|
513
555
|
const maxTop = Math.max(VIEWPORT_PAD, window.innerHeight - VIEWPORT_PAD - ph);
|
|
514
556
|
top = Math.min(Math.max(top, VIEWPORT_PAD), maxTop);
|
|
515
|
-
|
|
557
|
+
// -center balances the panel on the anchor's horizontal midpoint
|
|
558
|
+
// (still clamped, so a half-off-screen anchor keeps the panel
|
|
559
|
+
// readable instead of mirroring the overflow to both edges).
|
|
560
|
+
let left =
|
|
561
|
+
align === "center"
|
|
562
|
+
? r.left + (r.width - pw) / 2
|
|
563
|
+
: props.placement.endsWith("-end")
|
|
564
|
+
? r.right - pw
|
|
565
|
+
: r.left;
|
|
516
566
|
const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
|
|
517
567
|
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
|
|
518
568
|
coords.value = {
|
|
@@ -616,7 +666,11 @@ export default defineComponent({
|
|
|
616
666
|
</HIconButton>
|
|
617
667
|
</div>
|
|
618
668
|
<div class="hk-select-sheet-body" ref={sheetBodyRef}>
|
|
619
|
-
<div
|
|
669
|
+
<div
|
|
670
|
+
class="hk-select-sheet-list"
|
|
671
|
+
ref={sheetListRef}
|
|
672
|
+
style={surfaceCap.value}
|
|
673
|
+
>
|
|
620
674
|
<div class="hk-select-sheet-content" ref={sheetContentRef}>
|
|
621
675
|
{slots.default?.()}
|
|
622
676
|
</div>
|
|
@@ -647,6 +701,7 @@ export default defineComponent({
|
|
|
647
701
|
<div
|
|
648
702
|
ref={panelRef}
|
|
649
703
|
class="hk-select-popout"
|
|
704
|
+
style={surfaceCap.value}
|
|
650
705
|
aria-label={props.title || undefined}
|
|
651
706
|
onKeydown={forwardKeydown}
|
|
652
707
|
>
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* HkInput's box (border, radius, rhythm, focus ring) so a tag field sits
|
|
4
4
|
* flush beside plain inputs in the same form; the panel chrome mirrors
|
|
5
5
|
* HkAffixPicker's popup (search on top, rows below, NO inner scroll
|
|
6
|
-
* region — the HkSelectPanel window owns the single scrollbar
|
|
6
|
+
* region — the HkSelectPanel window owns the single scrollbar, capped by
|
|
7
|
+
* the component's `maxHeight` so a long catalog scrolls there instead of
|
|
8
|
+
* stretching the panel). Chips and selected rows are reorderable; the
|
|
9
|
+
* drag states (`data-dragging` / `data-drop`) live at the bottom. */
|
|
7
10
|
|
|
8
11
|
.hk-tag-input-wrapper {
|
|
9
12
|
display: block;
|
|
@@ -78,6 +81,15 @@
|
|
|
78
81
|
|
|
79
82
|
.hk-tag-input-tag {
|
|
80
83
|
max-width: 100%;
|
|
84
|
+
/* Reorder affordance: the chip BODY is the handle. */
|
|
85
|
+
cursor: grab;
|
|
86
|
+
/* A press that becomes a drag must not paint a text selection, and a
|
|
87
|
+
* touch that starts scrolling vertically belongs to the page (the
|
|
88
|
+
* browser takes the gesture back with a pointercancel; a horizontal
|
|
89
|
+
* drag is ours — see usePointerReorder). */
|
|
90
|
+
user-select: none;
|
|
91
|
+
-webkit-user-select: none;
|
|
92
|
+
touch-action: pan-y;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
.hk-tag-input-tag-text {
|
|
@@ -87,6 +99,14 @@
|
|
|
87
99
|
white-space: nowrap;
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/* A disabled field reorders nothing: the chips drop the grab affordance
|
|
103
|
+
* (and the gesture reservation with it) — the box's own not-allowed
|
|
104
|
+
* cursor takes over. */
|
|
105
|
+
.hk-tag-input-box[data-disabled] .hk-tag-input-tag {
|
|
106
|
+
cursor: default;
|
|
107
|
+
touch-action: auto;
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
/* ── inline typing input / placeholder ────────────────────────────── */
|
|
91
111
|
.hk-tag-input-element {
|
|
92
112
|
/* Auto-growing: the element's width is published inline in `ch`
|
|
@@ -171,6 +191,12 @@
|
|
|
171
191
|
.hk-tag-input-panel {
|
|
172
192
|
display: flex;
|
|
173
193
|
flex-direction: column;
|
|
194
|
+
/* A FIXED measure, not a content-driven one: the popout is positioned
|
|
195
|
+
* once when it opens, so a panel that grew with its content would shift
|
|
196
|
+
* its own left edge mid-interaction — toggling a row adds a check glyph,
|
|
197
|
+
* and a query changes which rows (and how much meta text) survive.
|
|
198
|
+
* min/max stay as the bounds a narrow viewport imposes on that width. */
|
|
199
|
+
width: min(19rem, calc(100vw - 2rem));
|
|
174
200
|
min-width: 13rem;
|
|
175
201
|
max-width: min(19rem, calc(100vw - 2rem));
|
|
176
202
|
}
|
|
@@ -291,6 +317,62 @@
|
|
|
291
317
|
font-variant-numeric: tabular-nums;
|
|
292
318
|
}
|
|
293
319
|
|
|
320
|
+
/* ── 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
|
+
.hk-tag-input-tag-dragging,
|
|
326
|
+
.hk-tag-input-row[data-dragging] {
|
|
327
|
+
opacity: 0.55;
|
|
328
|
+
cursor: grabbing;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.hk-tag-input-tag-drop {
|
|
332
|
+
outline: 1px dashed rgb(var(--color-focused-border));
|
|
333
|
+
outline-offset: 1px;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.hk-tag-input-row[data-drop] {
|
|
337
|
+
box-shadow: inset 0 0 0 1px rgb(var(--color-focused-border));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* Drag handle of a SELECTED panel row — the one element that claims the
|
|
341
|
+
* touch gesture outright, so the sheet keeps scrolling under the rest of
|
|
342
|
+
* the row. Only selected rows render one: an unselected row is not
|
|
343
|
+
* draggable and is never a drop target. */
|
|
344
|
+
.hk-tag-input-grip {
|
|
345
|
+
display: inline-flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
justify-content: center;
|
|
348
|
+
flex: none;
|
|
349
|
+
/* The largest target the row rhythm allows: the row's content box is the
|
|
350
|
+
2.25rem row minus its 4px block padding, so the handle grows to that
|
|
351
|
+
without changing the option rows' height (the 44px touch guidance
|
|
352
|
+
cannot fit in a list row — the sheet scrolls through them instead). */
|
|
353
|
+
width: 1.5rem;
|
|
354
|
+
height: 1.75rem;
|
|
355
|
+
margin-inline-end: -4px;
|
|
356
|
+
color: rgb(var(--color-muted));
|
|
357
|
+
opacity: 0.6;
|
|
358
|
+
cursor: grab;
|
|
359
|
+
touch-action: none;
|
|
360
|
+
user-select: none;
|
|
361
|
+
-webkit-user-select: none;
|
|
362
|
+
transition: opacity 0.12s ease, color 0.12s ease;
|
|
363
|
+
|
|
364
|
+
&:active {
|
|
365
|
+
cursor: grabbing;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.hk-tag-input-row:hover .hk-tag-input-grip,
|
|
370
|
+
.hk-tag-input-row[data-dragging] .hk-tag-input-grip,
|
|
371
|
+
.hk-tag-input-grip:focus-visible {
|
|
372
|
+
opacity: 1;
|
|
373
|
+
color: rgb(var(--color-text));
|
|
374
|
+
}
|
|
375
|
+
|
|
294
376
|
.hk-tag-input-empty {
|
|
295
377
|
padding: 14px 12px;
|
|
296
378
|
color: rgb(var(--color-muted));
|
|
@@ -301,6 +383,7 @@
|
|
|
301
383
|
@media (prefers-reduced-motion: reduce) {
|
|
302
384
|
.hk-tag-input-box,
|
|
303
385
|
.hk-tag-input-chevron,
|
|
386
|
+
.hk-tag-input-grip,
|
|
304
387
|
.hk-tag-input-row {
|
|
305
388
|
transition: none;
|
|
306
389
|
}
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
* `max-height: 17rem; overflow: auto` row region. It has none: the
|
|
7
7
|
* HkSelectPanel window surface (desktop popout / mobile sheet) owns THE
|
|
8
8
|
* single scrollbar and scrolls the search field with the rows as one.
|
|
9
|
+
*
|
|
10
|
+
* The 2026-09-11 geometry wave leans on the same contract from the other
|
|
11
|
+
* side: the field used to hand the panel its own width (`matchAnchorWidth`,
|
|
12
|
+
* a full-width settings column) while the catalog caps at 19rem, leaving an
|
|
13
|
+
* empty band and a scrollbar parked far from the rows. The panel now hugs
|
|
14
|
+
* its own measure, and the catalog scrolls because the SURFACE is capped
|
|
15
|
+
* (`maxHeight` → the --hk-select-panel-max-height hook) — never because the
|
|
16
|
+
* row list grew a scroll region of its own.
|
|
9
17
|
*/
|
|
10
18
|
import { describe, expect, it } from "vitest";
|
|
11
19
|
import { readFileSync } from "node:fs";
|
|
@@ -34,6 +42,14 @@ function cssBlock(src: string, selector: string): string {
|
|
|
34
42
|
return "";
|
|
35
43
|
}
|
|
36
44
|
|
|
45
|
+
/** The JSX opening tag that carries `marker` (attributes only, no
|
|
46
|
+
* children) — the pin below is about what ONE element declares. */
|
|
47
|
+
function jsxOpeningTag(src: string, marker: string): string {
|
|
48
|
+
const at = src.indexOf(marker);
|
|
49
|
+
expect(at, `${marker} is rendered`).toBeGreaterThanOrEqual(0);
|
|
50
|
+
return src.slice(src.lastIndexOf("<", at), src.indexOf(">", at) + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
function expectNoScroll(block: string, what: string): void {
|
|
38
54
|
expect(block, `${what} block exists`).toBeTruthy();
|
|
39
55
|
expect(block).not.toMatch(/max-height/);
|
|
@@ -61,13 +77,70 @@ describe("HkTagInput single-scrollbar-per-window contract", () => {
|
|
|
61
77
|
expectNoScroll(block, ".hk-select-sheet-panel .hk-tag-input-panel");
|
|
62
78
|
});
|
|
63
79
|
|
|
64
|
-
it("the component mounts no overlay scrollbar machinery", () => {
|
|
80
|
+
it("the component mounts no overlay scrollbar machinery and clamps no row region", () => {
|
|
65
81
|
expect(tsx).not.toContain("attachOverlayScrollbars");
|
|
66
82
|
expect(tsx).not.toContain("OverlayScrollbarHandle");
|
|
67
|
-
// No inline clamp on the row list either (the prose in the file
|
|
68
|
-
// mentions `max-height` in the contract comment, so this pins the
|
|
69
|
-
// style property spelling instead of the words).
|
|
70
|
-
expect(tsx).not.toContain("maxHeight");
|
|
71
83
|
expect(tsx).not.toContain("overflowY");
|
|
84
|
+
// The ONLY max-height in the file belongs to the panel SURFACE prop
|
|
85
|
+
// (the very next test): the listbox element itself stays unpinned, so
|
|
86
|
+
// the window keeps the one scroll region.
|
|
87
|
+
const listEl = jsxOpeningTag(tsx, 'class="hk-tag-input-list"');
|
|
88
|
+
expect(listEl.startsWith("<div")).toBe(true);
|
|
89
|
+
expect(listEl).not.toMatch(/style=/);
|
|
90
|
+
expect(listEl).not.toMatch(/maxHeight|max-height|overflow/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("hugs its own measure, and scrolls by capping the SURFACE (an additive prop)", () => {
|
|
94
|
+
const panel = jsxOpeningTag(tsx, "<HkSelectPanel");
|
|
95
|
+
// The field's width is not the panel's width any more…
|
|
96
|
+
expect(panel).toContain("matchAnchorWidth={false}");
|
|
97
|
+
// …and the catalog scrolls inside a capped surface instead of growing
|
|
98
|
+
// to the stylesheet ceiling.
|
|
99
|
+
expect(panel).toContain('maxHeight="min(18rem, 45dvh)"');
|
|
100
|
+
// The cap is the shared panel's own hook, and it is ADDITIVE: the
|
|
101
|
+
// historic ceiling stays the fallback, so every other consumer of
|
|
102
|
+
// HkSelectPanel renders exactly as before.
|
|
103
|
+
const panelScss = readFileSync(join(here, "HkSelect.scss"), "utf-8");
|
|
104
|
+
// The hook rides the dvh-capable branch: on an engine without dvh the
|
|
105
|
+
// plain-vh ceiling above it stays the cap — a var()-substituted dvh
|
|
106
|
+
// value would be invalid at computed-value time and mean "no cap".
|
|
107
|
+
const popout = panelScss.slice(
|
|
108
|
+
panelScss.indexOf(".hk-select-popout {"),
|
|
109
|
+
panelScss.indexOf(".hk-select-option"),
|
|
110
|
+
);
|
|
111
|
+
// …and the block's POSITION is load-bearing: the hook must come after
|
|
112
|
+
// both literal ceilings, or the dvh-capable branch would override them
|
|
113
|
+
// with its own fallback and the consumer cap would never apply.
|
|
114
|
+
expect(popout).toContain("@supports (height: 1dvh)");
|
|
115
|
+
expect(popout.indexOf("@supports")).toBeGreaterThan(
|
|
116
|
+
popout.indexOf("max-height: max(240px, min(36rem, calc(100vh - 32px)));"),
|
|
117
|
+
);
|
|
118
|
+
expect(popout.indexOf("@supports")).toBeGreaterThan(
|
|
119
|
+
popout.lastIndexOf("max-height: max(240px, min(36rem, calc(100dvh - 32px)));"),
|
|
120
|
+
);
|
|
121
|
+
expect(popout).toMatch(
|
|
122
|
+
/max-height:\s*var\(\s*--hk-select-panel-max-height,\s*max\(240px, min\(36rem, calc\(100dvh - 32px\)\)\)\s*\)/,
|
|
123
|
+
);
|
|
124
|
+
expect(popout).toContain("max-height: max(240px, min(36rem, calc(100vh - 32px)));");
|
|
125
|
+
// The sheet band the same prop caps falls back to being uncapped — the
|
|
126
|
+
// property's own initial value, i.e. today's behaviour.
|
|
127
|
+
expect(panelScss).toMatch(
|
|
128
|
+
/max-height:\s*var\(--hk-select-panel-max-height,\s*none\)/,
|
|
129
|
+
);
|
|
130
|
+
const panelTsx = readFileSync(join(here, "HkSelectPanel.tsx"), "utf-8");
|
|
131
|
+
expect(panelTsx).toContain("maxHeight: { type: String, default: undefined }");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("animates both lists through the shared list transition (FLIP move)", () => {
|
|
135
|
+
const groups = [...tsx.matchAll(/<HkListTransition\b[^>]*>/g)].map((m) => m[0]);
|
|
136
|
+
expect(groups, "the chips and the rows are both transition groups").toHaveLength(2);
|
|
137
|
+
for (const group of groups) {
|
|
138
|
+
expect(group).toContain('variant="reveal"');
|
|
139
|
+
expect(group).toMatch(/\bmove\b/);
|
|
140
|
+
// A fragment tag: the group adds no wrapper element, so the chips
|
|
141
|
+
// stay the field's own flex items and the rows stay the listbox's
|
|
142
|
+
// own children.
|
|
143
|
+
expect(group).toContain('tag=""');
|
|
144
|
+
}
|
|
72
145
|
});
|
|
73
146
|
});
|