@celestia-island/hikari 0.47.0 → 0.48.0
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/HkAuthMethodList.scss +89 -10
- package/src/components/HkAuthMethodList.test.tsx +33 -21
- package/src/components/HkAuthMethodList.tsx +58 -30
- package/src/components/HkBlockingToast.scss +3 -1
- package/src/components/HkIconButtonGroup.tsx +15 -7
- package/src/components/HkMenu.tsx +9 -6
- package/src/components/HkModal.tsx +17 -1
- package/src/components/HkModal.width.test.tsx +6 -2
- package/src/components/HkPopover.scss +4 -3
- package/src/components/HkPopover.sheet-maxwidth.test.ts +3 -2
- package/src/components/HkPopover.surfacevars.test.ts +3 -1
- package/src/components/HkPopover.tsx +14 -11
- package/src/components/HkSelect.scss +11 -0
- package/src/components/HkSelectPanel.popoutwidth.test.ts +47 -0
- package/src/components/HkSelectPanel.test.tsx +12 -10
- package/src/components/HkSelectPanel.tsx +10 -10
- package/src/components/HkToast.scss +3 -1
- package/src/components/HkTooltip.scss +8 -0
- package/src/components/HkTooltip.test.tsx +12 -0
- package/src/components/HkTooltip.tsx +15 -9
- package/src/runtime/index.ts +10 -0
- package/src/runtime/tooltipBridge.ts +10 -6
- package/src/runtime/tooltipPosition.test.ts +213 -2
- package/src/runtime/tooltipPosition.ts +159 -0
- package/src/runtime/viewportGutter.test.ts +98 -0
- package/src/runtime/viewportGutter.ts +73 -0
- package/src/scale.scss +17 -0
- package/src/styles/theme/scale.scss +17 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the select popout host's position-independent width
|
|
3
|
+
* (2026-09-14 user report: the "jump to date" tooltip on a screen-edge
|
|
4
|
+
* FAB collapsed to a one-glyph column — the same shrink-to-fit class the
|
|
5
|
+
* popover fixed in #421. HkSelectPanel's desktop popout was the last
|
|
6
|
+
* anchored surface still sizing against `viewport - left`; an anchor
|
|
7
|
+
* near the right edge squeezed the panel down to the leftover space).
|
|
8
|
+
*
|
|
9
|
+
* The host must keep:
|
|
10
|
+
* - `width: max-content` — size to the rows wherever the anchor sits;
|
|
11
|
+
* - the viewport cap on `--viewport-gutter` (16px desktop / 8px
|
|
12
|
+
* mobile) — the panel can never exceed the space between the
|
|
13
|
+
* gutters, matching .hk-popover-panel;
|
|
14
|
+
* - the `min-width: 180px` floor — which must stay BEATABLE by the
|
|
15
|
+
* inline matchAnchorWidth style (stylesheet loses to inline on the
|
|
16
|
+
* same element; declaration ORDER inside this block is irrelevant,
|
|
17
|
+
* so it is deliberately not pinned here).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const scss = readFileSync(join(here, "HkSelect.scss"), "utf-8");
|
|
26
|
+
|
|
27
|
+
describe("HkSelectPanel popout viewport width contract", () => {
|
|
28
|
+
const block = scss.match(/\.hk-select-popout-host\s*{[\s\S]*?^\}/m)?.[0] ?? "";
|
|
29
|
+
|
|
30
|
+
it("declares the host block", () => {
|
|
31
|
+
expect(block).not.toBe("");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("sizes the popout to its content (width: max-content)", () => {
|
|
35
|
+
expect(block).toContain("width: max-content;");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("caps the popout at the viewport gutter on both edges", () => {
|
|
39
|
+
expect(block).toContain(
|
|
40
|
+
"max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps the 180px floor on the host (narrow anchors never collapse the panel)", () => {
|
|
45
|
+
expect(block).toContain("min-width: 180px;");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -324,13 +324,14 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
324
324
|
expect(host.dataset.side).toBe("top");
|
|
325
325
|
|
|
326
326
|
// A centered panel poking past the left edge clamps to the viewport
|
|
327
|
-
//
|
|
328
|
-
// 0..40 → raw left (20 - 180/2) = -70 → clamped to
|
|
327
|
+
// gutter instead of mirroring the overflow to both sides: anchor
|
|
328
|
+
// 0..40 → raw left (20 - 180/2) = -70 → clamped to the desktop
|
|
329
|
+
// gutter (16px; happy-dom's viewport is desktop-width).
|
|
329
330
|
anchor.getBoundingClientRect = () =>
|
|
330
331
|
({ top: 500, bottom: 520, left: 0, right: 40, width: 40, height: 20 }) as DOMRect;
|
|
331
332
|
window.dispatchEvent(new Event("resize"));
|
|
332
333
|
await nextTick();
|
|
333
|
-
expect(host.style.left).toBe("
|
|
334
|
+
expect(host.style.left).toBe("16px");
|
|
334
335
|
});
|
|
335
336
|
|
|
336
337
|
it("reports center alignment through an auto-flip so the pop origin follows", async () => {
|
|
@@ -371,7 +372,8 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
371
372
|
|
|
372
373
|
it("clamps a centered popout at the right viewport pad near the right edge", async () => {
|
|
373
374
|
// Anchor hugging the right edge: raw balanced left 1100 + (80 - 180)/2
|
|
374
|
-
// = 1050 would poke past 1200 -
|
|
375
|
+
// = 1050 would poke past 1200 - 16 - 180 = 1004 → pinned at the
|
|
376
|
+
// desktop gutter.
|
|
375
377
|
// The expected left edge is viewport arithmetic, so pin the width here
|
|
376
378
|
// (happy-dom's bare default is 1024 — the afterEach reset only helps
|
|
377
379
|
// full-file runs, not -t filtering; afterEach re-asserts 1200 anyway).
|
|
@@ -386,7 +388,7 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
386
388
|
await nextTick();
|
|
387
389
|
|
|
388
390
|
const host = document.body.querySelector<HTMLElement>(".hk-select-popout-host")!;
|
|
389
|
-
expect(host.style.left).toBe("
|
|
391
|
+
expect(host.style.left).toBe("1004px");
|
|
390
392
|
});
|
|
391
393
|
|
|
392
394
|
it("clamps a tall flipped popout into the viewport instead of going negative", async () => {
|
|
@@ -419,11 +421,11 @@ describe("HkSelectPanel custom invocation", () => {
|
|
|
419
421
|
await nextTick();
|
|
420
422
|
|
|
421
423
|
const top = Number.parseInt(host.style.top, 10);
|
|
422
|
-
// Whole panel on-screen: top edge at/inside the viewport
|
|
423
|
-
// bottom edge inside it too — overflow beyond that is the
|
|
424
|
-
// own internal scroll, never off-screen geometry.
|
|
425
|
-
expect(top).toBeGreaterThanOrEqual(
|
|
426
|
-
expect(top + 576).toBeLessThanOrEqual(800 -
|
|
424
|
+
// Whole panel on-screen: top edge at/inside the viewport gutter and
|
|
425
|
+
// the bottom edge inside it too — overflow beyond that is the
|
|
426
|
+
// panel's own internal scroll, never off-screen geometry.
|
|
427
|
+
expect(top).toBeGreaterThanOrEqual(16);
|
|
428
|
+
expect(top + 576).toBeLessThanOrEqual(800 - 16);
|
|
427
429
|
} finally {
|
|
428
430
|
window.innerHeight = prevHeight;
|
|
429
431
|
}
|
|
@@ -14,6 +14,7 @@ import { useOverlay } from "../runtime/useOverlay";
|
|
|
14
14
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
15
15
|
import { createBackGuard } from "../runtime/backStack";
|
|
16
16
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
17
|
+
import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
|
|
17
18
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
18
19
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
19
20
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
@@ -72,8 +73,6 @@ export type SelectPanelPlacement =
|
|
|
72
73
|
| "top-center"
|
|
73
74
|
| "top-end";
|
|
74
75
|
|
|
75
|
-
const VIEWPORT_PAD = 8;
|
|
76
|
-
|
|
77
76
|
export default defineComponent({
|
|
78
77
|
name: "HkSelectPanel",
|
|
79
78
|
props: {
|
|
@@ -542,16 +541,19 @@ export default defineComponent({
|
|
|
542
541
|
const phRaw = panelRef.value?.offsetHeight || 0;
|
|
543
542
|
const pw = pwRaw > 0 ? pwRaw * z : Math.max(r.width, 180);
|
|
544
543
|
const ph = phRaw > 0 ? phRaw * z : 200;
|
|
544
|
+
// The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
|
|
545
|
+
// desktop), read per positioning pass.
|
|
546
|
+
const pad = viewportGutterPx();
|
|
545
547
|
let side: "top" | "bottom" = props.placement.startsWith("top-") ? "top" : "bottom";
|
|
546
548
|
let top =
|
|
547
549
|
side === "top"
|
|
548
550
|
? r.top - props.offset - ph
|
|
549
551
|
: r.bottom + props.offset;
|
|
550
552
|
// Auto-flip when the chosen side cannot host the panel.
|
|
551
|
-
if (side === "bottom" && top + ph > window.innerHeight -
|
|
553
|
+
if (side === "bottom" && top + ph > window.innerHeight - pad) {
|
|
552
554
|
side = "top";
|
|
553
555
|
top = r.top - props.offset - ph;
|
|
554
|
-
} else if (side === "top" && top <
|
|
556
|
+
} else if (side === "top" && top < pad) {
|
|
555
557
|
side = "bottom";
|
|
556
558
|
top = r.bottom + props.offset;
|
|
557
559
|
}
|
|
@@ -566,22 +568,20 @@ export default defineComponent({
|
|
|
566
568
|
// bottom→top into a negative top that was applied verbatim. Clamp so
|
|
567
569
|
// the whole panel stays on-screen; when content exceeds the CSS cap
|
|
568
570
|
// the panel's own internal scroll takes over.
|
|
569
|
-
|
|
570
|
-
top = Math.min(Math.max(top, VIEWPORT_PAD), maxTop);
|
|
571
|
+
top = clampWithGutter(top, ph, window.innerHeight, pad);
|
|
571
572
|
// -center balances the panel on the anchor's horizontal midpoint
|
|
572
573
|
// (still clamped, so a half-off-screen anchor keeps the panel
|
|
573
574
|
// readable instead of mirroring the overflow to both edges).
|
|
574
|
-
|
|
575
|
+
const left =
|
|
575
576
|
align === "center"
|
|
576
577
|
? r.left + (r.width - pw) / 2
|
|
577
578
|
: props.placement.endsWith("-end")
|
|
578
579
|
? r.right - pw
|
|
579
580
|
: r.left;
|
|
580
|
-
const
|
|
581
|
-
left = Math.min(Math.max(left, VIEWPORT_PAD), maxLeft);
|
|
581
|
+
const clampedLeft = clampWithGutter(left, pw, window.innerWidth, pad);
|
|
582
582
|
coords.value = {
|
|
583
583
|
top: `${Math.round(top / z)}px`,
|
|
584
|
-
left: `${Math.round(
|
|
584
|
+
left: `${Math.round(clampedLeft / z)}px`,
|
|
585
585
|
...(props.matchAnchorWidth ? { minWidth: `${Math.round(r.width / z)}px` } : {}),
|
|
586
586
|
};
|
|
587
587
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
.hk-toast-container {
|
|
2
2
|
position: fixed;
|
|
3
3
|
top: 4rem;
|
|
4
|
-
|
|
4
|
+
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
5
|
+
* 8px mobile) — the toast stack is a floating layer too. */
|
|
6
|
+
inset-inline-end: var(--viewport-gutter, 1rem);
|
|
5
7
|
/* Toast band — the topmost popup z band (POPUP_Z_BANDS.toast; mirror in
|
|
6
8
|
theme.scss). The live value arrives inline from the popup manager. */
|
|
7
9
|
z-index: var(--hk-z-toast, 4000);
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
padding: var(--space-4) var(--space-8);
|
|
17
17
|
border: none;
|
|
18
18
|
border-radius: var(--radius-sm);
|
|
19
|
+
/* Width is position-independent (same contract as .hk-popover-panel):
|
|
20
|
+
* max-content sizes the bubble to its text wherever it sits — a fixed
|
|
21
|
+
* element otherwise shrink-to-fits against `viewport - left` and a
|
|
22
|
+
* trigger near the screen edge squeezes it into a one-glyph column.
|
|
23
|
+
* The inline style from runtime/tooltipPosition carries the same
|
|
24
|
+
* declaration; this sheet rule keeps the bridge/standalone faces
|
|
25
|
+
* covered even where the inline pass does not run. */
|
|
26
|
+
width: max-content;
|
|
19
27
|
/* Default cap lives on the popup so the inline maxWidth prop
|
|
20
28
|
legitimately overrides it (the content div would shadow the prop). */
|
|
21
29
|
max-width: 280px;
|
|
@@ -223,4 +223,16 @@ describe("HkTooltip anchoring", () => {
|
|
|
223
223
|
await settle();
|
|
224
224
|
expect(popup().style.maxWidth).toBe("220px");
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
it("sizes the popup to its content (max-content) for the gutter clamp", async () => {
|
|
228
|
+
// The compression fix: the popup box must be position-independent
|
|
229
|
+
// (width: max-content) so a screen-edge trigger cannot shrink it to
|
|
230
|
+
// the containing block's leftover space; applyTooltipPosition then
|
|
231
|
+
// clamps the measured box into the viewport gutter (no-op here —
|
|
232
|
+
// happy-dom reports a zero rect).
|
|
233
|
+
const { container } = mount({ text: "sized", delay: 0 });
|
|
234
|
+
enter(container);
|
|
235
|
+
await settle();
|
|
236
|
+
expect(popup().style.width).toBe("max-content");
|
|
237
|
+
});
|
|
226
238
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, Teleport, type CSSProperties, type PropType } from "vue";
|
|
2
2
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
3
|
-
import {
|
|
3
|
+
import { applyTooltipPosition, type TooltipPlacement } from "../runtime/tooltipPosition";
|
|
4
4
|
import "./HkTooltip.scss";
|
|
5
5
|
|
|
6
6
|
export default defineComponent({
|
|
@@ -14,7 +14,7 @@ export default defineComponent({
|
|
|
14
14
|
setup(props, { slots }) {
|
|
15
15
|
const visible = ref(false);
|
|
16
16
|
const wrapperRef = ref<HTMLElement | null>(null);
|
|
17
|
-
const
|
|
17
|
+
const popupRef = ref<HTMLElement | null>(null);
|
|
18
18
|
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
|
19
19
|
|
|
20
20
|
// Registers with the popup manager (kind "tooltip") so tooltips hold
|
|
@@ -31,13 +31,16 @@ export default defineComponent({
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
function updatePosition() {
|
|
34
|
-
if (!wrapperRef.value) return;
|
|
34
|
+
if (!wrapperRef.value || !popupRef.value) return;
|
|
35
35
|
const rect = wrapperRef.value.getBoundingClientRect();
|
|
36
36
|
// Placement geometry lives in the shared runtime helper (it also
|
|
37
37
|
// serves the document-level tooltip bridge) — including the
|
|
38
38
|
// ancestor-zoom division that keeps teleported popups pinned to
|
|
39
|
-
// their trigger inside scaled roots
|
|
40
|
-
|
|
39
|
+
// their trigger inside scaled roots, and the measure-and-clamp pass
|
|
40
|
+
// that keeps the measured bubble inside the viewport gutter (flip,
|
|
41
|
+
// cap, shift) instead of spilling off-screen or shrinking to the
|
|
42
|
+
// containing block's leftover space.
|
|
43
|
+
applyTooltipPosition(popupRef.value, rect, props.placement, props.maxWidth);
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
function show() {
|
|
@@ -74,10 +77,12 @@ export default defineComponent({
|
|
|
74
77
|
visible.value ? "hk-tooltip-visible" : "",
|
|
75
78
|
]);
|
|
76
79
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
// Only the popup-manager z rides the vnode; the geometry is written
|
|
81
|
+
// straight onto the element by applyTooltipPosition (same as the
|
|
82
|
+
// tooltip bridge) so a re-render can never clobber a clamp shift.
|
|
83
|
+
const popupStyle = computed<CSSProperties>(() =>
|
|
84
|
+
zIndex.value != null ? { zIndex: zIndex.value } : {},
|
|
85
|
+
);
|
|
81
86
|
|
|
82
87
|
return () => (
|
|
83
88
|
<span
|
|
@@ -94,6 +99,7 @@ export default defineComponent({
|
|
|
94
99
|
</span>
|
|
95
100
|
<Teleport to="body">
|
|
96
101
|
<div
|
|
102
|
+
ref={popupRef}
|
|
97
103
|
class={tooltipCls.value}
|
|
98
104
|
style={popupStyle.value}
|
|
99
105
|
>
|
package/src/runtime/index.ts
CHANGED
|
@@ -13,9 +13,19 @@ export {
|
|
|
13
13
|
} from "./imageFallback";
|
|
14
14
|
export {
|
|
15
15
|
tooltipPositionStyle,
|
|
16
|
+
applyTooltipPosition,
|
|
17
|
+
resolveTooltipFlip,
|
|
16
18
|
TOOLTIP_GAP_PX,
|
|
17
19
|
type TooltipPlacement,
|
|
18
20
|
} from "./tooltipPosition";
|
|
21
|
+
export {
|
|
22
|
+
viewportGutterPx,
|
|
23
|
+
clampWithGutter,
|
|
24
|
+
VIEWPORT_GUTTER_VAR,
|
|
25
|
+
MOBILE_GUTTER_PX,
|
|
26
|
+
DESKTOP_GUTTER_PX,
|
|
27
|
+
GUTTER_MOBILE_MAX_WIDTH,
|
|
28
|
+
} from "./viewportGutter";
|
|
19
29
|
export {
|
|
20
30
|
sanitizeHistoryUrl,
|
|
21
31
|
installHistorySafetyNet,
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// → the element's title is moved aside into `data-hk-title` and the
|
|
10
10
|
// attribute is REMOVED — the native tooltip can no longer appear
|
|
11
11
|
// → after the delay the shared `.hk-tooltip-popup` element is filled,
|
|
12
|
-
// positioned (
|
|
13
|
-
// HkTooltip) and faded in
|
|
12
|
+
// positioned (applyTooltipPosition: same placement/zoom math as
|
|
13
|
+
// HkTooltip, plus the viewport-gutter clamp) and faded in
|
|
14
14
|
// disengage (pointerout / focusout / scroll / resize / Esc); pointerdown
|
|
15
15
|
// only hides the popup — the hold stays so no native tooltip flashes
|
|
16
16
|
// → the popup fades out and the title attribute is RESTORED, so
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// popups share the tooltip z band with component tooltips. Installing
|
|
29
29
|
// twice on the same document is idempotent: the second install returns
|
|
30
30
|
// the first install's uninstall.
|
|
31
|
-
import {
|
|
31
|
+
import { applyTooltipPosition, type TooltipPlacement } from "./tooltipPosition";
|
|
32
32
|
import { usePopupManager, type PopupHandle } from "./usePopupManager";
|
|
33
33
|
import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
|
|
34
34
|
// The popup reuses HkTooltip's popup classes — carry the sheet so a host
|
|
@@ -127,12 +127,16 @@ function showPopup(state: BridgeState, el: Element) {
|
|
|
127
127
|
if (!text || !state.popup.isConnected) return;
|
|
128
128
|
state.content.textContent = text;
|
|
129
129
|
// Same geometry as component tooltips: the fixed popup pins to the
|
|
130
|
-
// trigger rect with the shared placement switch (ancestor zoom aware)
|
|
131
|
-
|
|
130
|
+
// trigger rect with the shared placement switch (ancestor zoom aware),
|
|
131
|
+
// then the measured box is clamped into the viewport gutter — a title
|
|
132
|
+
// trigger hugging the screen edge shows a full-width bubble shifted
|
|
133
|
+
// inside the gutter, never the squeezed leftover-space column.
|
|
134
|
+
applyTooltipPosition(
|
|
135
|
+
state.popup,
|
|
132
136
|
el.getBoundingClientRect(),
|
|
133
137
|
state.placement,
|
|
134
138
|
state.maxWidth,
|
|
135
|
-
)
|
|
139
|
+
);
|
|
136
140
|
state.popup.classList.add("hk-tooltip-visible");
|
|
137
141
|
el.setAttribute("aria-describedby", state.describedBy);
|
|
138
142
|
state.shown += 1;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
TOOLTIP_GAP_PX,
|
|
5
|
+
applyTooltipPosition,
|
|
6
|
+
resolveTooltipFlip,
|
|
7
|
+
tooltipPositionStyle,
|
|
8
|
+
} from "./tooltipPosition";
|
|
4
9
|
|
|
5
10
|
/** A 100x40 rect at (200,100) — big enough for every placement side. */
|
|
6
11
|
function rect(): DOMRect {
|
|
@@ -11,6 +16,34 @@ function rect(): DOMRect {
|
|
|
11
16
|
} as DOMRect;
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
function box(left: number, top: number, width: number, height: number): DOMRect {
|
|
20
|
+
return {
|
|
21
|
+
left, top, width, height,
|
|
22
|
+
right: left + width, bottom: top + height,
|
|
23
|
+
x: left, y: top,
|
|
24
|
+
toJSON: () => ({}),
|
|
25
|
+
} as DOMRect;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A probe element whose gBCR answers with a queued rect sequence. */
|
|
29
|
+
function probePopup(rects: DOMRect[]): HTMLElement {
|
|
30
|
+
const el = document.createElement("div");
|
|
31
|
+
el.dataset.tooltipProbe = "true";
|
|
32
|
+
document.body.appendChild(el);
|
|
33
|
+
let i = 0;
|
|
34
|
+
el.getBoundingClientRect = () => rects[Math.min(i++, rects.length - 1)]!;
|
|
35
|
+
return el;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PREV_INNER = { width: window.innerWidth, height: window.innerHeight };
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
window.innerWidth = PREV_INNER.width;
|
|
42
|
+
window.innerHeight = PREV_INNER.height;
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
document.querySelectorAll(".hk-tooltip-popup, [data-tooltip-probe]").forEach((el) => el.remove());
|
|
45
|
+
});
|
|
46
|
+
|
|
14
47
|
describe("tooltipPositionStyle", () => {
|
|
15
48
|
it("pins the popup 8px outside the rect with per-placement transforms", () => {
|
|
16
49
|
expect(TOOLTIP_GAP_PX).toBe(8);
|
|
@@ -38,4 +71,182 @@ describe("tooltipPositionStyle", () => {
|
|
|
38
71
|
expect(style.maxWidth).toBe("180px");
|
|
39
72
|
expect(tooltipPositionStyle(rect(), "top").maxWidth).toBeUndefined();
|
|
40
73
|
});
|
|
74
|
+
|
|
75
|
+
it("sizes the popup to its content so the containing block cannot compress it", () => {
|
|
76
|
+
// THE compression fix: a fixed element shrink-to-fits against
|
|
77
|
+
// `viewport - left`, so a trigger near the right edge used to squeeze
|
|
78
|
+
// the bubble into a one-glyph column. max-content is position-
|
|
79
|
+
// independent (same contract as .hk-popover-panel).
|
|
80
|
+
expect(tooltipPositionStyle(rect(), "top").width).toBe("max-content");
|
|
81
|
+
expect(tooltipPositionStyle(rect(), "right", "120px").width).toBe("max-content");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("applyTooltipPosition without layout", () => {
|
|
86
|
+
it("degrades to the pure placement style when the box measures zero", () => {
|
|
87
|
+
const popup = probePopup([box(0, 0, 0, 0)]);
|
|
88
|
+
applyTooltipPosition(popup, rect(), "top");
|
|
89
|
+
expect(popup.style.top).toBe("92px");
|
|
90
|
+
expect(popup.style.left).toBe("250px");
|
|
91
|
+
expect(popup.style.transform).toBe("translate(-50%, -100%)");
|
|
92
|
+
expect(popup.style.width).toBe("max-content");
|
|
93
|
+
// No clamp pass ran: no caps, no gutter writes.
|
|
94
|
+
expect(popup.style.maxWidth).toBe("");
|
|
95
|
+
expect(popup.style.maxHeight).toBe("");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("still honors the maxWidth prop on the zero-layout path", () => {
|
|
99
|
+
const popup = probePopup([box(0, 0, 0, 0)]);
|
|
100
|
+
applyTooltipPosition(popup, rect(), "top", "220px");
|
|
101
|
+
expect(popup.style.maxWidth).toBe("220px");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("applyTooltipPosition viewport clamping", () => {
|
|
106
|
+
it("shifts a centered bubble back inside the right-edge gutter", () => {
|
|
107
|
+
// Mobile viewport (375px → 8px gutter). Trigger at 300..340, the
|
|
108
|
+
// centered 120px bubble would span 260..380 — 13px past the gutter.
|
|
109
|
+
window.innerWidth = 375;
|
|
110
|
+
window.innerHeight = 667;
|
|
111
|
+
const trigger = box(300, 300, 40, 40);
|
|
112
|
+
const popup = probePopup([box(260, 348, 120, 32)]);
|
|
113
|
+
applyTooltipPosition(popup, trigger, "bottom");
|
|
114
|
+
// The shift lands on the WRITTEN anchor (style.left is the box
|
|
115
|
+
// center under translate(-50%)): 320 - 13 = 307, visual box 247..367.
|
|
116
|
+
expect(popup.style.left).toBe("307px");
|
|
117
|
+
expect(popup.style.top).toBe("348px");
|
|
118
|
+
expect(popup.style.maxWidth).toBe("");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("flips to the opposite side when the preferred one is starved", () => {
|
|
122
|
+
// Trigger hugging the top edge: a top popup cannot clear the gutter,
|
|
123
|
+
// bottom has room → the popup flips below the trigger.
|
|
124
|
+
window.innerWidth = 375;
|
|
125
|
+
window.innerHeight = 667;
|
|
126
|
+
const trigger = box(100, 2, 40, 18);
|
|
127
|
+
const popup = probePopup([box(120, -22, 200, 32), box(120, 28, 200, 32)]);
|
|
128
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
129
|
+
expect(popup.style.transform).toBe("translate(-50%, 0)");
|
|
130
|
+
expect(popup.style.top).toBe("28px"); // rect.bottom + 8
|
|
131
|
+
expect(popup.style.left).toBe("120px"); // hcenter, unshifted
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("caps a bubble that cannot fit between the gutters, then shifts", () => {
|
|
135
|
+
// A 400px bubble on a 375px viewport: max-width caps it to
|
|
136
|
+
// 375 - 2*8 = 359px and the centered remainder shifts inside.
|
|
137
|
+
window.innerWidth = 375;
|
|
138
|
+
window.innerHeight = 667;
|
|
139
|
+
const trigger = box(100, 300, 40, 40);
|
|
140
|
+
const popup = probePopup([box(-80, 232, 400, 60), box(-80, 232, 359, 60)]);
|
|
141
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
142
|
+
expect(popup.style.maxWidth).toBe("359px");
|
|
143
|
+
// Base anchor 120 - shift dx 88 (visual -80 → gutter 8) = 208.
|
|
144
|
+
expect(popup.style.left).toBe("208px");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("clips a viewport-tall tooltip instead of spilling past the edge", () => {
|
|
148
|
+
window.innerWidth = 375;
|
|
149
|
+
window.innerHeight = 667;
|
|
150
|
+
// Preferred side keeps the flip away (top space 392 > bottom 219) so
|
|
151
|
+
// the case isolates the height cap + clip.
|
|
152
|
+
const trigger = box(160, 400, 40, 40);
|
|
153
|
+
const popup = probePopup([box(100, 20, 160, 700), box(100, 20, 160, 651)]);
|
|
154
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
155
|
+
expect(popup.style.maxHeight).toBe("651px"); // 667 - 2*8
|
|
156
|
+
expect(popup.style.overflow).toBe("hidden");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("writes the shift divided by the cumulative root zoom", () => {
|
|
160
|
+
// chest's root-level DPI zoom: style px are local (divided by the
|
|
161
|
+
// zoom) while the measured box and the viewport are visual.
|
|
162
|
+
window.innerWidth = 1200;
|
|
163
|
+
window.innerHeight = 800;
|
|
164
|
+
const original = window.getComputedStyle.bind(window);
|
|
165
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(((el: Element) => {
|
|
166
|
+
const decl = original(el);
|
|
167
|
+
return new Proxy(decl, {
|
|
168
|
+
get(target, prop, recv) {
|
|
169
|
+
if (prop === "zoom") return el === document.documentElement ? "2" : undefined;
|
|
170
|
+
const v = Reflect.get(target, prop, recv);
|
|
171
|
+
return typeof v === "function" ? (v as (...a: unknown[]) => unknown).bind(target) : v;
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}) as typeof window.getComputedStyle);
|
|
175
|
+
const trigger = box(1180, 100, 20, 40);
|
|
176
|
+
// Base right placement at zoom 2: left = (1200+8)/2 = 604 local, the
|
|
177
|
+
// 300px bubble spans visual 1208..1508 — past the gutter AND the
|
|
178
|
+
// starved right side, so it flips left and shifts the remainder.
|
|
179
|
+
const popup = probePopup([box(1208, 100, 300, 40), box(886, 100, 300, 40)]);
|
|
180
|
+
applyTooltipPosition(popup, trigger, "right");
|
|
181
|
+
expect(popup.style.transform).toBe("translate(-100%, -50%)");
|
|
182
|
+
// Flip rewrite: (1180 - 8)/2 = 586 local; visual box 886..1186, 2px
|
|
183
|
+
// past the 1184 gutter line → dx -2 visual = -1 local: 586 - 1 = 585.
|
|
184
|
+
expect(popup.style.left).toBe("585px");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("keeps the width cap through a flip (cap + flip combined)", () => {
|
|
188
|
+
// R1 verification defect: applyBase on the flipped side used to reset
|
|
189
|
+
// maxWidth, so the uncapped box was measured and pinned — one edge at
|
|
190
|
+
// the gutter, the opposite 33px PAST the viewport. The cap belongs to
|
|
191
|
+
// the box, not the side: it must survive the flip.
|
|
192
|
+
window.innerWidth = 375;
|
|
193
|
+
window.innerHeight = 667;
|
|
194
|
+
const trigger = box(300, 300, 40, 40); // right edge at 340
|
|
195
|
+
const popup = probePopup([
|
|
196
|
+
box(348, 304, 400, 32), // base "right": 400px wide → cap 359px
|
|
197
|
+
box(348, 304, 359, 32), // capped re-measure → right side starved
|
|
198
|
+
box(-67, 304, 359, 32), // flipped "left": uncapped-width visual box
|
|
199
|
+
]);
|
|
200
|
+
applyTooltipPosition(popup, trigger, "right");
|
|
201
|
+
expect(popup.style.maxWidth).toBe("359px"); // must survive the flip
|
|
202
|
+
expect(popup.style.transform).toBe("translate(-100%, -50%)");
|
|
203
|
+
// Shift pins 292 + 75 = 367 → visual box 8..367, exactly inside the
|
|
204
|
+
// gutters on BOTH edges (375 - 8 = 367).
|
|
205
|
+
expect(popup.style.left).toBe("367px");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("keeps the height cap and clip through a flip (cap + flip combined)", () => {
|
|
209
|
+
// The near-top-edge tall tooltip: guaranteed flip (the top side
|
|
210
|
+
// cannot hold it) — the height cap must not be reset by the flip
|
|
211
|
+
// rewrite, or the pinned box spills 41px past the bottom edge.
|
|
212
|
+
window.innerWidth = 375;
|
|
213
|
+
window.innerHeight = 667;
|
|
214
|
+
const trigger = box(160, 4, 40, 18);
|
|
215
|
+
const popup = probePopup([
|
|
216
|
+
box(100, -704, 160, 700), // base "top": 700px tall → cap 651px
|
|
217
|
+
box(100, -704, 160, 651), // capped re-measure → top starved
|
|
218
|
+
box(100, 30, 160, 651), // flipped "bottom" visual box
|
|
219
|
+
]);
|
|
220
|
+
applyTooltipPosition(popup, trigger, "top");
|
|
221
|
+
expect(popup.style.maxHeight).toBe("651px");
|
|
222
|
+
expect(popup.style.overflow).toBe("hidden");
|
|
223
|
+
expect(popup.style.transform).toBe("translate(-50%, 0)");
|
|
224
|
+
// 30 - 22 = 8 → visual box 8..659, inside the bottom gutter (667-8).
|
|
225
|
+
expect(popup.style.top).toBe("8px");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("resolveTooltipFlip", () => {
|
|
230
|
+
const vw = 375;
|
|
231
|
+
const vh = 667;
|
|
232
|
+
const gutter = 8;
|
|
233
|
+
|
|
234
|
+
it("keeps the placement when the preferred side fits", () => {
|
|
235
|
+
const trigger = box(100, 100, 40, 40);
|
|
236
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("flips to the opposite side when it has more room", () => {
|
|
240
|
+
const trigger = box(100, 2, 40, 18);
|
|
241
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 32 }, vw, vh, gutter)).toBe("bottom");
|
|
242
|
+
const floorTrigger = box(100, 640, 40, 20);
|
|
243
|
+
expect(resolveTooltipFlip(floorTrigger, "bottom", { width: 200, height: 32 }, vw, vh, gutter)).toBe("top");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps the placement when the opposite side is no better (the clamp handles it)", () => {
|
|
247
|
+
// Both sides starved, opposite NOT strictly larger → keep the author's
|
|
248
|
+
// placement; the gutter clamp pins the popup on-screen.
|
|
249
|
+
const trigger = box(100, 2, 40, 663); // spaceAbove -6, spaceBelow -6
|
|
250
|
+
expect(resolveTooltipFlip(trigger, "top", { width: 200, height: 400 }, vw, vh, gutter)).toBe("top");
|
|
251
|
+
});
|
|
41
252
|
});
|