@celestia-island/hikari 0.43.0 → 0.43.1
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
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
|
|
|
@@ -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
|
|
@@ -56,8 +56,10 @@ import "./HkSelect.scss";
|
|
|
56
56
|
|
|
57
57
|
export type SelectPanelPlacement =
|
|
58
58
|
| "bottom-start"
|
|
59
|
+
| "bottom-center"
|
|
59
60
|
| "bottom-end"
|
|
60
61
|
| "top-start"
|
|
62
|
+
| "top-center"
|
|
61
63
|
| "top-end";
|
|
62
64
|
|
|
63
65
|
const VIEWPORT_PAD = 8;
|
|
@@ -71,7 +73,10 @@ export default defineComponent({
|
|
|
71
73
|
anchorRef: { type: Object as PropType<HTMLElement | null>, default: null },
|
|
72
74
|
/** Sheet header on mobile / a11y name for the panel. */
|
|
73
75
|
title: { type: String, default: "" },
|
|
74
|
-
/** Base placement of the popout relative to the anchor (auto-flips).
|
|
76
|
+
/** Base placement of the popout relative to the anchor (auto-flips).
|
|
77
|
+
* The `-center` variants center the popout on the anchor's horizontal
|
|
78
|
+
* midpoint (footer-center triggers whose menus would otherwise read
|
|
79
|
+
* as lopsided off a narrow pill). */
|
|
75
80
|
placement: {
|
|
76
81
|
type: String as PropType<SelectPanelPlacement>,
|
|
77
82
|
default: "bottom-start",
|
|
@@ -474,9 +479,10 @@ export default defineComponent({
|
|
|
474
479
|
// again after the DOM settles, when the panel's real box is known and
|
|
475
480
|
// flip/clamp decisions can use actual numbers.
|
|
476
481
|
/** 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
|
-
|
|
482
|
+
* transform-origin (grow from the anchor edge or midpoint,
|
|
483
|
+
* data-side/align on the host). Updated by positionPanel on every
|
|
484
|
+
* reposition. */
|
|
485
|
+
const resolved = ref<{ side: "top" | "bottom"; align: "start" | "center" | "end" }>({
|
|
480
486
|
side: "bottom",
|
|
481
487
|
align: "start",
|
|
482
488
|
});
|
|
@@ -501,10 +507,12 @@ export default defineComponent({
|
|
|
501
507
|
side = "bottom";
|
|
502
508
|
top = r.bottom + props.offset;
|
|
503
509
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
510
|
+
const align = props.placement.endsWith("-center")
|
|
511
|
+
? "center"
|
|
512
|
+
: props.placement.endsWith("-end")
|
|
513
|
+
? "end"
|
|
514
|
+
: "start";
|
|
515
|
+
resolved.value = { side, align };
|
|
508
516
|
// One flip never re-checks: taller menu panels (the viewport-relative
|
|
509
517
|
// CSS cap) made this band reachable — a mid-viewport anchor flips
|
|
510
518
|
// bottom→top into a negative top that was applied verbatim. Clamp so
|
|
@@ -512,7 +520,15 @@ export default defineComponent({
|
|
|
512
520
|
// the panel's own internal scroll takes over.
|
|
513
521
|
const maxTop = Math.max(VIEWPORT_PAD, window.innerHeight - VIEWPORT_PAD - ph);
|
|
514
522
|
top = Math.min(Math.max(top, VIEWPORT_PAD), maxTop);
|
|
515
|
-
|
|
523
|
+
// -center balances the panel on the anchor's horizontal midpoint
|
|
524
|
+
// (still clamped, so a half-off-screen anchor keeps the panel
|
|
525
|
+
// readable instead of mirroring the overflow to both edges).
|
|
526
|
+
let left =
|
|
527
|
+
align === "center"
|
|
528
|
+
? r.left + (r.width - pw) / 2
|
|
529
|
+
: props.placement.endsWith("-end")
|
|
530
|
+
? r.right - pw
|
|
531
|
+
: r.left;
|
|
516
532
|
const maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
|
|
517
533
|
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
|
|
518
534
|
coords.value = {
|