@celestia-island/hikari 0.47.1 → 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/HkBlockingToast.scss +3 -1
- 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
package/package.json
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
.hk-blocking-toast-container {
|
|
10
10
|
position: fixed;
|
|
11
11
|
top: 4rem;
|
|
12
|
-
|
|
12
|
+
/* Same viewport gutter the anchored popups clamp to (16px desktop /
|
|
13
|
+
* 8px mobile) — the toast stack is a floating layer too. */
|
|
14
|
+
inset-inline-end: var(--viewport-gutter, 1rem);
|
|
13
15
|
z-index: var(--hk-z-toast, 4000);
|
|
14
16
|
max-width: 24rem;
|
|
15
17
|
pointer-events: none;
|
|
@@ -14,6 +14,7 @@ import { Check, ChevronRight } from "lucide-vue-next";
|
|
|
14
14
|
|
|
15
15
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
16
16
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
17
|
+
import { viewportGutterPx } from "../runtime/viewportGutter";
|
|
17
18
|
import HkSelectPanel, { type SelectPanelPlacement } from "./HkSelectPanel";
|
|
18
19
|
import "./HkMenu.scss";
|
|
19
20
|
|
|
@@ -58,8 +59,6 @@ export interface HkMenuItem {
|
|
|
58
59
|
children?: HkMenuItem[];
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/** Viewport padding mirrored from HkSelectPanel's popout geometry. */
|
|
62
|
-
const VIEWPORT_PAD = 8;
|
|
63
62
|
/** Width estimate used ONLY to pick a cascade's flip side; the real
|
|
64
63
|
* panel box is measured by HkSelectPanel itself. */
|
|
65
64
|
const CASCADE_PANEL_W = 224;
|
|
@@ -415,8 +414,10 @@ export default defineComponent({
|
|
|
415
414
|
const r = row.getBoundingClientRect();
|
|
416
415
|
if (!r.width && !r.height) return pointRect(0, 0); // detached
|
|
417
416
|
const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
|
|
418
|
-
|
|
419
|
-
const
|
|
417
|
+
// Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
|
|
418
|
+
const pad = viewportGutterPx();
|
|
419
|
+
const openRight = r.right + cascadeW <= window.innerWidth - pad;
|
|
420
|
+
const left = openRight ? r.right : Math.max(pad, r.left - cascadeW);
|
|
420
421
|
return pointRect(left, r.top);
|
|
421
422
|
},
|
|
422
423
|
contains: (node) => !!rowRefs.value[id]?.contains(node),
|
|
@@ -438,11 +439,13 @@ export default defineComponent({
|
|
|
438
439
|
if (!r.width && !r.height) return pointRect(0, 0);
|
|
439
440
|
const gap = props.offset; // visual-space gap, same as the native placements
|
|
440
441
|
const cascadeW = CASCADE_PANEL_W * ancestorZoom(document.body);
|
|
441
|
-
|
|
442
|
+
// Shared viewport gutter (--viewport-gutter: 8 mobile / 16 desktop).
|
|
443
|
+
const pad = viewportGutterPx();
|
|
444
|
+
const openRight = r.right + gap + cascadeW <= window.innerWidth - pad;
|
|
442
445
|
const left =
|
|
443
446
|
side === "right" && openRight
|
|
444
447
|
? r.right + gap
|
|
445
|
-
: Math.max(
|
|
448
|
+
: Math.max(pad, r.left - cascadeW - gap);
|
|
446
449
|
return pointRect(left, r.top);
|
|
447
450
|
},
|
|
448
451
|
contains: (node) => !!props.anchorRef?.contains(node),
|
|
@@ -115,6 +115,20 @@ export function resolveModalWidth(width: string): string {
|
|
|
115
115
|
return value;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Cap the resolved max-width so the centered desktop frame always keeps
|
|
120
|
+
* the shared viewport gutter (--viewport-gutter: 16px desktop / 8px
|
|
121
|
+
* mobile) clear on both sides — a frame wider than the window can spare
|
|
122
|
+
* used to run edge-to-edge. `100%` resolves against the fixed frame's
|
|
123
|
+
* containing block (the initial containing block), so the cap tracks the
|
|
124
|
+
* live window; the ≤767px sheet branch overrides max-width with
|
|
125
|
+
* `100% !important` in the SCSS, so the intentional full-bleed mobile
|
|
126
|
+
* sheet is untouched.
|
|
127
|
+
*/
|
|
128
|
+
export function modalFrameMaxWidth(resolved: string): string {
|
|
129
|
+
return `min(${resolved}, calc(100% - 2 * var(--viewport-gutter, 16px)))`;
|
|
130
|
+
}
|
|
131
|
+
|
|
118
132
|
const FOCUSABLE_SELECTOR =
|
|
119
133
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
120
134
|
|
|
@@ -194,7 +208,9 @@ export default defineComponent({
|
|
|
194
208
|
|
|
195
209
|
const overlayZ = computed(() => handle.value?.zIndex ?? 0);
|
|
196
210
|
const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
|
|
197
|
-
const resolvedWidth = computed(() =>
|
|
211
|
+
const resolvedWidth = computed(() =>
|
|
212
|
+
modalFrameMaxWidth(resolveModalWidth(props.width)),
|
|
213
|
+
);
|
|
198
214
|
// Layer/dialog name: the explicit surface name wins over the header
|
|
199
215
|
// title (which header-less surfaces never pass).
|
|
200
216
|
const resolvedSurfaceName = computed(() => props.surfaceTitle ?? props.title);
|
|
@@ -104,11 +104,15 @@ describe("HkModal width rendering", () => {
|
|
|
104
104
|
|
|
105
105
|
it("renders a named preset as the frame's max-width", async () => {
|
|
106
106
|
const content = await mountModal("sm");
|
|
107
|
-
expect(content.style.maxWidth).toBe(
|
|
107
|
+
expect(content.style.maxWidth).toBe(
|
|
108
|
+
"min(32rem, calc(100% - 2 * var(--viewport-gutter, 16px)))",
|
|
109
|
+
);
|
|
108
110
|
});
|
|
109
111
|
|
|
110
112
|
it("renders an arbitrary CSS length as the frame's max-width", async () => {
|
|
111
113
|
const content = await mountModal("560px");
|
|
112
|
-
expect(content.style.maxWidth).toBe(
|
|
114
|
+
expect(content.style.maxWidth).toBe(
|
|
115
|
+
"min(560px, calc(100% - 2 * var(--viewport-gutter, 16px)))",
|
|
116
|
+
);
|
|
113
117
|
});
|
|
114
118
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
.hk-popover-panel {
|
|
11
11
|
min-width: 0;
|
|
12
|
-
max-height: min(70vh, calc(100vh - 2 *
|
|
12
|
+
max-height: min(70vh, calc(100vh - 2 * var(--viewport-gutter, 16px)));
|
|
13
13
|
/* Width MUST be position-independent (max-content, viewport-capped).
|
|
14
14
|
* The anchored panel is fixed-positioned with shrink-to-fit sizing,
|
|
15
15
|
* and computePosition places it as `left = anchorEnd - panelWidth`
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* wider frame by frame (demo.dev field report 2026-09-07). Pinning
|
|
22
22
|
* the width to max-content breaks the loop: the panel sizes to its
|
|
23
23
|
* content wherever it sits, and positioning converges in one pass.
|
|
24
|
-
*
|
|
24
|
+
* The script-side pad reads the same --viewport-gutter token
|
|
25
|
+
* (runtime/viewportGutter). */
|
|
25
26
|
width: max-content;
|
|
26
|
-
max-width: calc(100vw - 2 *
|
|
27
|
+
max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/* Pop motion family tokens (--hk-pop-* in theme.scss) — the reference
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* / HkSelectPanel sheets, hence "some have it, some don't").
|
|
7
7
|
*
|
|
8
8
|
* Root cause: the base `.hk-popover-panel` rule sets
|
|
9
|
-
* `max-width: calc(100vw - 2 *
|
|
10
|
-
* anti-ratchet clamp, #421
|
|
9
|
+
* `max-width: calc(100vw - 2 * <viewport pad>)` (the anchored panel's
|
|
10
|
+
* anti-ratchet clamp, #421; today the shared `--viewport-gutter` token).
|
|
11
|
+
* The mobile sheet branch overrides
|
|
11
12
|
* `width: auto` but inherited the max-width, over-constraining the
|
|
12
13
|
* inline `left: 0; right: 0` docking — the used width resolved to
|
|
13
14
|
* 100vw - 16px and LTR dropped the `right` constraint. Pinned here so
|
|
@@ -27,7 +27,9 @@ describe("HkPopover glass layer surface hooks", () => {
|
|
|
27
27
|
it("pins the anchored panel width to max-content and resets the sheet", () => {
|
|
28
28
|
const base = src.match(/\.hk-popover-panel\s*{[^}]*}/)![0];
|
|
29
29
|
expect(base).toContain("width: max-content");
|
|
30
|
-
|
|
30
|
+
// The viewport caps ride the shared --viewport-gutter token (16px
|
|
31
|
+
// desktop / 8px mobile) — the same value the script-side clamp reads.
|
|
32
|
+
expect(base).toContain("max-width: calc(100vw - 2 * var(--viewport-gutter, 16px))");
|
|
31
33
|
const sheet = src.match(/\.hk-popover-panel\.hk-is-sheet\s*{[^}]*}/)![0];
|
|
32
34
|
expect(sheet).toContain("width: auto");
|
|
33
35
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { usePopupManager, type PopupHandle } from "../runtime/usePopupManager";
|
|
16
16
|
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
17
17
|
import { ancestorZoom } from "../runtime/cssZoom";
|
|
18
|
+
import { clampWithGutter, viewportGutterPx } from "../runtime/viewportGutter";
|
|
18
19
|
import { useI18n } from "../i18n/context";
|
|
19
20
|
import { useSurfaceTransition } from "../composables/useSurfaceTransition";
|
|
20
21
|
import { useSurfaceMachine } from "../composables/useSurfaceMachine";
|
|
@@ -39,8 +40,6 @@ function parsePlacement(p: PopupPlacement): { side: BaseSide; align: Align } {
|
|
|
39
40
|
return { side, align: align ?? "center" };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
const VIEWPORT_PAD = 8;
|
|
43
|
-
|
|
44
43
|
export default defineComponent({
|
|
45
44
|
name: "HkPopover",
|
|
46
45
|
props: {
|
|
@@ -323,6 +322,10 @@ export default defineComponent({
|
|
|
323
322
|
}
|
|
324
323
|
const vw = window.innerWidth;
|
|
325
324
|
const vh = window.innerHeight;
|
|
325
|
+
// The shared viewport gutter (--viewport-gutter: 8px mobile / 16px
|
|
326
|
+
// desktop) — read per reposition so a viewport crossing the
|
|
327
|
+
// breakpoint re-clamps with the right value on the next pass.
|
|
328
|
+
const pad = viewportGutterPx();
|
|
326
329
|
|
|
327
330
|
let { side } = parsePlacement(props.placement);
|
|
328
331
|
const { align } = parsePlacement(props.placement);
|
|
@@ -331,25 +334,25 @@ export default defineComponent({
|
|
|
331
334
|
if (side === "bottom") {
|
|
332
335
|
const spaceBelow = vh - anchorRect.bottom;
|
|
333
336
|
const spaceAbove = anchorRect.top;
|
|
334
|
-
if (spaceBelow < panelRect.height +
|
|
337
|
+
if (spaceBelow < panelRect.height + pad && spaceAbove > spaceBelow) {
|
|
335
338
|
side = "top";
|
|
336
339
|
}
|
|
337
340
|
} else if (side === "top") {
|
|
338
341
|
const spaceAbove = anchorRect.top;
|
|
339
342
|
const spaceBelow = vh - anchorRect.bottom;
|
|
340
|
-
if (spaceAbove < panelRect.height +
|
|
343
|
+
if (spaceAbove < panelRect.height + pad && spaceBelow > spaceAbove) {
|
|
341
344
|
side = "bottom";
|
|
342
345
|
}
|
|
343
346
|
} else if (side === "right") {
|
|
344
347
|
const spaceRight = vw - anchorRect.right;
|
|
345
348
|
const spaceLeft = anchorRect.left;
|
|
346
|
-
if (spaceRight < panelRect.width +
|
|
349
|
+
if (spaceRight < panelRect.width + pad && spaceLeft > spaceRight) {
|
|
347
350
|
side = "left";
|
|
348
351
|
}
|
|
349
352
|
} else if (side === "left") {
|
|
350
353
|
const spaceLeft = anchorRect.left;
|
|
351
354
|
const spaceRight = vw - anchorRect.right;
|
|
352
|
-
if (spaceLeft < panelRect.width +
|
|
355
|
+
if (spaceLeft < panelRect.width + pad && spaceRight > spaceLeft) {
|
|
353
356
|
side = "right";
|
|
354
357
|
}
|
|
355
358
|
}
|
|
@@ -374,7 +377,7 @@ export default defineComponent({
|
|
|
374
377
|
} else {
|
|
375
378
|
crossPos = anchorStart + (anchorSize - panelSize) / 2;
|
|
376
379
|
}
|
|
377
|
-
crossPos =
|
|
380
|
+
crossPos = clampWithGutter(crossPos, panelSize, viewportSize, pad);
|
|
378
381
|
|
|
379
382
|
if (side === "bottom") {
|
|
380
383
|
c.top = anchorRect.bottom + off;
|
|
@@ -391,13 +394,13 @@ export default defineComponent({
|
|
|
391
394
|
}
|
|
392
395
|
|
|
393
396
|
if (side === "bottom") {
|
|
394
|
-
c.top =
|
|
397
|
+
c.top = clampWithGutter(c.top!, panelRect.height, vh, pad);
|
|
395
398
|
} else if (side === "top") {
|
|
396
|
-
c.bottom =
|
|
399
|
+
c.bottom = clampWithGutter(c.bottom ?? 0, panelRect.height, vh, pad);
|
|
397
400
|
} else if (side === "right") {
|
|
398
|
-
c.left =
|
|
401
|
+
c.left = clampWithGutter(c.left!, panelRect.width, vw, pad);
|
|
399
402
|
} else {
|
|
400
|
-
c.right =
|
|
403
|
+
c.right = clampWithGutter(c.right ?? 0, panelRect.width, vw, pad);
|
|
401
404
|
}
|
|
402
405
|
|
|
403
406
|
coords.value = c;
|
|
@@ -119,6 +119,17 @@
|
|
|
119
119
|
position: fixed;
|
|
120
120
|
min-width: 180px;
|
|
121
121
|
|
|
122
|
+
/* Width is position-independent (same contract as .hk-popover-panel):
|
|
123
|
+
* max-content sizes the popout to its rows wherever it sits, instead
|
|
124
|
+
* of shrink-to-fitting against `viewport - left` — an anchor near the
|
|
125
|
+
* right screen edge used to squeeze the panel down to the leftover
|
|
126
|
+
* space. The viewport cap (2 × the --viewport-gutter token, 16px
|
|
127
|
+
* desktop / 8px mobile) bounds it on both edges; the inline
|
|
128
|
+
* matchAnchorWidth min-width still wins over both, as documented
|
|
129
|
+
* above. */
|
|
130
|
+
width: max-content;
|
|
131
|
+
max-width: calc(100vw - 2 * var(--viewport-gutter, 16px));
|
|
132
|
+
|
|
122
133
|
/* Pop motion family — the popout grows out of the anchor edge (or
|
|
123
134
|
* midpoint, for the -center placements) it sits on (data-side/data-align
|
|
124
135
|
* are set by HkSelectPanel from the RESOLVED placement, including after
|
|
@@ -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
|
});
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// positions it through this helper so the two surfaces can never drift.
|
|
5
5
|
import type { CSSProperties } from "vue";
|
|
6
6
|
import { ancestorZoom } from "./cssZoom";
|
|
7
|
+
import { clampWithGutter, viewportGutterPx } from "./viewportGutter";
|
|
7
8
|
|
|
8
9
|
export type TooltipPlacement = "top" | "bottom" | "left" | "right";
|
|
9
10
|
|
|
@@ -20,6 +21,14 @@ export const TOOLTIP_GAP_PX = 8;
|
|
|
20
21
|
* px back up at paint, so the visual rect values must be written divided
|
|
21
22
|
* by the cumulative zoom or the tooltip drifts zoom× off its trigger
|
|
22
23
|
* (chest's root-level manual DPI scale).
|
|
24
|
+
*
|
|
25
|
+
* `width: max-content` is the position-independence contract (same rule
|
|
26
|
+
* as .hk-popover-panel): a fixed element otherwise shrink-to-fits against
|
|
27
|
+
* `viewport - left`, so a trigger near the viewport edge squeezes the
|
|
28
|
+
* bubble down to the leftover space — the vertical one-glyph-per-line
|
|
29
|
+
* column on mobile (the jump-to-date tooltip report). max-content sizes
|
|
30
|
+
* the box to its text wherever it sits; applyTooltipPosition then clamps
|
|
31
|
+
* it back into the viewport.
|
|
23
32
|
*/
|
|
24
33
|
export function tooltipPositionStyle(
|
|
25
34
|
rect: DOMRect,
|
|
@@ -30,6 +39,7 @@ export function tooltipPositionStyle(
|
|
|
30
39
|
const local = (v: number) => `${v / z}px`;
|
|
31
40
|
const style: CSSProperties = {};
|
|
32
41
|
|
|
42
|
+
style.width = "max-content";
|
|
33
43
|
if (maxWidth) {
|
|
34
44
|
style.maxWidth = maxWidth;
|
|
35
45
|
}
|
|
@@ -59,3 +69,152 @@ export function tooltipPositionStyle(
|
|
|
59
69
|
|
|
60
70
|
return style;
|
|
61
71
|
}
|
|
72
|
+
|
|
73
|
+
/** Space left on each side of the trigger rect, minus the trigger gap. */
|
|
74
|
+
function sideSpace(
|
|
75
|
+
rect: DOMRect,
|
|
76
|
+
side: TooltipPlacement,
|
|
77
|
+
vw: number,
|
|
78
|
+
vh: number,
|
|
79
|
+
): number {
|
|
80
|
+
switch (side) {
|
|
81
|
+
case "top":
|
|
82
|
+
return rect.top - TOOLTIP_GAP_PX;
|
|
83
|
+
case "bottom":
|
|
84
|
+
return vh - rect.bottom - TOOLTIP_GAP_PX;
|
|
85
|
+
case "left":
|
|
86
|
+
return rect.left - TOOLTIP_GAP_PX;
|
|
87
|
+
case "right":
|
|
88
|
+
return vw - rect.right - TOOLTIP_GAP_PX;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The side opposite `placement` (the only flip tooltips ever need). */
|
|
93
|
+
function oppositeSide(placement: TooltipPlacement): TooltipPlacement {
|
|
94
|
+
switch (placement) {
|
|
95
|
+
case "top":
|
|
96
|
+
return "bottom";
|
|
97
|
+
case "bottom":
|
|
98
|
+
return "top";
|
|
99
|
+
case "left":
|
|
100
|
+
return "right";
|
|
101
|
+
case "right":
|
|
102
|
+
return "left";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Flip a top↔bottom / left↔right preference when the requested side
|
|
108
|
+
* cannot hold the measured popup inside the viewport gutter but the
|
|
109
|
+
* opposite side can — same criterion as HkPopover's autoFlip, so the
|
|
110
|
+
* surfaces rule their geometry identically. Returns the placement to use.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveTooltipFlip(
|
|
113
|
+
rect: DOMRect,
|
|
114
|
+
placement: TooltipPlacement,
|
|
115
|
+
popup: { width: number; height: number },
|
|
116
|
+
vw: number,
|
|
117
|
+
vh: number,
|
|
118
|
+
gutter: number,
|
|
119
|
+
): TooltipPlacement {
|
|
120
|
+
const preferred = sideSpace(rect, placement, vw, vh);
|
|
121
|
+
const need = (placement === "top" || placement === "bottom" ? popup.height : popup.width) + gutter;
|
|
122
|
+
if (preferred >= need) return placement;
|
|
123
|
+
const alternate = oppositeSide(placement);
|
|
124
|
+
const alternateSpace = sideSpace(rect, alternate, vw, vh);
|
|
125
|
+
return alternateSpace > preferred ? alternate : placement;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Position a live popup element against a trigger rect: apply the shared
|
|
130
|
+
* placement style, then measure the REAL box and pull it inside the
|
|
131
|
+
* viewport gutter — capping an oversized bubble, flipping the side when
|
|
132
|
+
* the preferred one is starved, and shifting the remainder so no edge
|
|
133
|
+
* ever crosses the gutter (the mobile/desktop --viewport-gutter token).
|
|
134
|
+
*
|
|
135
|
+
* Without layout (happy-dom/jsdom, hidden popups) measurement reports a
|
|
136
|
+
* zero rect and the call degrades to exactly the pure
|
|
137
|
+
* tooltipPositionStyle output — the clamps are additive, never
|
|
138
|
+
* load-bearing for the base anchor math.
|
|
139
|
+
*/
|
|
140
|
+
export function applyTooltipPosition(
|
|
141
|
+
popup: HTMLElement,
|
|
142
|
+
rect: DOMRect,
|
|
143
|
+
placement: TooltipPlacement,
|
|
144
|
+
maxWidth?: string,
|
|
145
|
+
): void {
|
|
146
|
+
// Fresh base every call: a previous show's caps (maxWidth/maxHeight)
|
|
147
|
+
// must not leak into this one's box.
|
|
148
|
+
const applyBase = (side: TooltipPlacement) => {
|
|
149
|
+
popup.style.maxWidth = maxWidth ?? "";
|
|
150
|
+
popup.style.maxHeight = "";
|
|
151
|
+
popup.style.overflow = "";
|
|
152
|
+
Object.assign(popup.style, tooltipPositionStyle(rect, side, maxWidth));
|
|
153
|
+
};
|
|
154
|
+
applyBase(placement);
|
|
155
|
+
|
|
156
|
+
const box = popup.getBoundingClientRect();
|
|
157
|
+
if (!(box.width > 0 && box.height > 0)) return; // no layout — pure base stands
|
|
158
|
+
|
|
159
|
+
const vw = window.innerWidth;
|
|
160
|
+
const vh = window.innerHeight;
|
|
161
|
+
const gutter = viewportGutterPx();
|
|
162
|
+
const z = ancestorZoom(popup);
|
|
163
|
+
|
|
164
|
+
// Cap a bubble that cannot fit between the gutters: max-width reflows
|
|
165
|
+
// the text (break-word wraps it), max-height clips the pathological
|
|
166
|
+
// viewport-tall title (tooltips are pointer-events: none — scrolling
|
|
167
|
+
// one is unreachable, so clipping beats spilling past the edge). The
|
|
168
|
+
// caps live in their own writer because the FLIP below rewrites the
|
|
169
|
+
// base style — without re-applying them, the flipped side would
|
|
170
|
+
// measure and pin the UNCAPPED box and spill past the opposite edge.
|
|
171
|
+
let capW = "";
|
|
172
|
+
let capH = "";
|
|
173
|
+
let clip = false;
|
|
174
|
+
const maxW = vw - 2 * gutter;
|
|
175
|
+
const maxH = vh - 2 * gutter;
|
|
176
|
+
if (box.width > maxW) {
|
|
177
|
+
capW = `${maxW / z}px`;
|
|
178
|
+
}
|
|
179
|
+
if (box.height > maxH) {
|
|
180
|
+
capH = `${maxH / z}px`;
|
|
181
|
+
clip = true;
|
|
182
|
+
}
|
|
183
|
+
const applyCaps = () => {
|
|
184
|
+
if (capW) popup.style.maxWidth = capW;
|
|
185
|
+
if (capH) {
|
|
186
|
+
popup.style.maxHeight = capH;
|
|
187
|
+
popup.style.overflow = "hidden";
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
let measured = box;
|
|
192
|
+
if (capW || capH) {
|
|
193
|
+
applyCaps();
|
|
194
|
+
measured = popup.getBoundingClientRect();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const side = resolveTooltipFlip(rect, placement, measured, vw, vh, gutter);
|
|
198
|
+
let final = measured;
|
|
199
|
+
if (side !== placement) {
|
|
200
|
+
applyBase(side);
|
|
201
|
+
applyCaps(); // caps survive the flip — they belong to the box, not the side
|
|
202
|
+
final = popup.getBoundingClientRect();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Shift whatever is left over (centered under a near-edge trigger, the
|
|
206
|
+
// flipped side, a cap) so both edges sit inside the gutter. The delta
|
|
207
|
+
// is measured in root visual px but the written coordinates are local
|
|
208
|
+
// (divided by the cumulative zoom) AND offset from the box edge by the
|
|
209
|
+
// placement transform (translate(-50%) makes style.left the box
|
|
210
|
+
// CENTER) — so the shift adds onto the current written value instead
|
|
211
|
+
// of the measured edge.
|
|
212
|
+
const dx = clampWithGutter(final.left, final.width, vw, gutter) - final.left;
|
|
213
|
+
const dy = clampWithGutter(final.top, final.height, vh, gutter) - final.top;
|
|
214
|
+
if (dx !== 0 || dy !== 0) {
|
|
215
|
+
const curLeft = Number.parseFloat(popup.style.left) || 0;
|
|
216
|
+
const curTop = Number.parseFloat(popup.style.top) || 0;
|
|
217
|
+
popup.style.left = `${curLeft + dx / z}px`;
|
|
218
|
+
popup.style.top = `${curTop + dy / z}px`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DESKTOP_GUTTER_PX,
|
|
8
|
+
MOBILE_GUTTER_PX,
|
|
9
|
+
VIEWPORT_GUTTER_VAR,
|
|
10
|
+
clampWithGutter,
|
|
11
|
+
viewportGutterPx,
|
|
12
|
+
} from "./viewportGutter";
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const scaleSrc = readFileSync(join(here, "../scale.scss"), "utf-8");
|
|
16
|
+
const vendoredSrc = readFileSync(join(here, "../styles/theme/scale.scss"), "utf-8");
|
|
17
|
+
|
|
18
|
+
const PREV_INNER_WIDTH = window.innerWidth;
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
window.innerWidth = PREV_INNER_WIDTH;
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Force the :root computed style to answer `value` for the gutter var. */
|
|
26
|
+
function stubRootVar(value: string) {
|
|
27
|
+
const original = window.getComputedStyle.bind(window);
|
|
28
|
+
vi.spyOn(window, "getComputedStyle").mockImplementation(((el: Element) => {
|
|
29
|
+
if (el === document.documentElement) {
|
|
30
|
+
return {
|
|
31
|
+
getPropertyValue: (name: string) => (name === VIEWPORT_GUTTER_VAR ? value : ""),
|
|
32
|
+
} as unknown as CSSStyleDeclaration;
|
|
33
|
+
}
|
|
34
|
+
return original(el);
|
|
35
|
+
}) as typeof window.getComputedStyle);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("viewportGutterPx", () => {
|
|
39
|
+
it("reads a positive px token off :root verbatim", () => {
|
|
40
|
+
stubRootVar("20px");
|
|
41
|
+
expect(viewportGutterPx()).toBe(20);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores non-px, garbage and non-positive token values", () => {
|
|
45
|
+
// A var() can reach the reader unresolved ("1rem" parseFloats to 1)
|
|
46
|
+
// and a garbage value must never SHRINK the gutter — all fall back.
|
|
47
|
+
for (const bad of ["", " ", "bogus", "1rem", "0px", "-4px", "16"]) {
|
|
48
|
+
stubRootVar(bad);
|
|
49
|
+
expect(viewportGutterPx(), `token ${JSON.stringify(bad)}`).toBe(
|
|
50
|
+
PREV_INNER_WIDTH < 768 ? MOBILE_GUTTER_PX : DESKTOP_GUTTER_PX,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("falls back by breakpoint without a token: 8px mobile / 16px desktop", () => {
|
|
56
|
+
window.innerWidth = 375;
|
|
57
|
+
expect(viewportGutterPx()).toBe(MOBILE_GUTTER_PX);
|
|
58
|
+
window.innerWidth = 767;
|
|
59
|
+
expect(viewportGutterPx()).toBe(MOBILE_GUTTER_PX);
|
|
60
|
+
window.innerWidth = 768;
|
|
61
|
+
expect(viewportGutterPx()).toBe(DESKTOP_GUTTER_PX);
|
|
62
|
+
window.innerWidth = 1280;
|
|
63
|
+
expect(viewportGutterPx()).toBe(DESKTOP_GUTTER_PX);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps the token and the breakpoint fallback numerically in sync", () => {
|
|
67
|
+
// The media query in scale.scss writes the same numbers this module
|
|
68
|
+
// falls back to — the contract that lets CSS caps and JS clamps
|
|
69
|
+
// never drift.
|
|
70
|
+
expect(scaleSrc).toContain(`${VIEWPORT_GUTTER_VAR}: ${DESKTOP_GUTTER_PX}px;`);
|
|
71
|
+
const media = scaleSrc.match(
|
|
72
|
+
new RegExp(`@media \\(max-width: 767px\\)\\s*{\\s*:root\\s*{[^}]*${VIEWPORT_GUTTER_VAR}: ${MOBILE_GUTTER_PX}px;`),
|
|
73
|
+
);
|
|
74
|
+
expect(media, "mobile media override in scale.scss").not.toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ships the token in the vendored scale sheet too", () => {
|
|
78
|
+
// vendoredSync.test.ts enforces byte-level sync; this pins the
|
|
79
|
+
// semantic (a host loading only the composed sheets still gets the
|
|
80
|
+
// token the JS reader looks for).
|
|
81
|
+
expect(vendoredSrc).toContain(`${VIEWPORT_GUTTER_VAR}: ${DESKTOP_GUTTER_PX}px;`);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("clampWithGutter", () => {
|
|
86
|
+
it("keeps a fitting box untouched", () => {
|
|
87
|
+
expect(clampWithGutter(100, 200, 1200, 16)).toBe(100);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("pins an overflowing box to the far gutter", () => {
|
|
91
|
+
expect(clampWithGutter(1100, 200, 1200, 16)).toBe(984);
|
|
92
|
+
expect(clampWithGutter(-70, 180, 1200, 16)).toBe(16);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("collapses to the gutter when the box cannot fit at all", () => {
|
|
96
|
+
expect(clampWithGutter(50, 5000, 1200, 16)).toBe(16);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Shared viewport-edge gutter — the one number every floating layer keeps
|
|
2
|
+
// between itself and the viewport edge.
|
|
3
|
+
//
|
|
4
|
+
// HkPopover / HkSelectPanel / HkMenu each used to carry a private
|
|
5
|
+
// `VIEWPORT_PAD = 8` with no desktop variant, and HkTooltip carried no
|
|
6
|
+
// clamp at all (a tooltip anchored near the right edge was squeezed to
|
|
7
|
+
// the leftover shrink-to-fit space — one or two glyphs per line). This
|
|
8
|
+
// module replaces all of them: the magnitude lives in the
|
|
9
|
+
// `--viewport-gutter` L2 token (scale.scss, 16px desktop / 8px mobile via
|
|
10
|
+
// the <768px media query), and the JS side reads that SAME token so the
|
|
11
|
+
// CSS caps (`calc(100vw - 2 * var(--viewport-gutter))`) and the clamp
|
|
12
|
+
// math can never drift apart. Hosts that never load the scale sheet fall
|
|
13
|
+
// back to the same numbers derived from innerWidth.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately NOT zoom-corrected: the consumers (HkPopover's visual-px
|
|
16
|
+
// clamp, tooltipPosition's visual-space shift) all work in root visual
|
|
17
|
+
// coordinates and pass the value straight through, matching the 8px
|
|
18
|
+
// behavior this replaces. A one-zoom-step error in an 8–16px gutter is
|
|
19
|
+
// invisible; a missing clamp was not.
|
|
20
|
+
|
|
21
|
+
/** The L2 token carrying the gutter (declared in scale.scss). */
|
|
22
|
+
export const VIEWPORT_GUTTER_VAR = "--viewport-gutter";
|
|
23
|
+
|
|
24
|
+
/** Fallback gutter on mobile-width viewports (innerWidth < 768). */
|
|
25
|
+
export const MOBILE_GUTTER_PX = 8;
|
|
26
|
+
|
|
27
|
+
/** Fallback gutter on desktop-width viewports, and the SSR answer. */
|
|
28
|
+
export const DESKTOP_GUTTER_PX = 16;
|
|
29
|
+
|
|
30
|
+
/** The breakpoint the CSS media query in scale.scss mirrors. */
|
|
31
|
+
export const GUTTER_MOBILE_MAX_WIDTH = 768;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The viewport gutter in CSS px. Reads `--viewport-gutter` off
|
|
35
|
+
* `:root` when it resolves to a positive px length (the shipped scale
|
|
36
|
+
* sheet always declares it), else falls back to 8px below the 768px
|
|
37
|
+
* breakpoint and 16px at or above it — the exact numbers the media
|
|
38
|
+
* query writes.
|
|
39
|
+
*/
|
|
40
|
+
export function viewportGutterPx(win?: Window | null): number {
|
|
41
|
+
if (typeof window === "undefined") return DESKTOP_GUTTER_PX;
|
|
42
|
+
const w = win ?? window;
|
|
43
|
+
try {
|
|
44
|
+
const raw = w
|
|
45
|
+
.getComputedStyle(w.document.documentElement)
|
|
46
|
+
.getPropertyValue(VIEWPORT_GUTTER_VAR)
|
|
47
|
+
.trim();
|
|
48
|
+
// px-only by contract: a var() can reach us unresolved ("1rem" would
|
|
49
|
+
// parseFloat to 1) and a garbage value must never shrink the gutter.
|
|
50
|
+
if (raw.endsWith("px")) {
|
|
51
|
+
const parsed = Number.parseFloat(raw);
|
|
52
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
/* no DOM — fall through to the breakpoint fallback */
|
|
56
|
+
}
|
|
57
|
+
return w.innerWidth < GUTTER_MOBILE_MAX_WIDTH ? MOBILE_GUTTER_PX : DESKTOP_GUTTER_PX;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clamp a cross/main-axis coordinate so a box of `size` px stays inside
|
|
62
|
+
* `[gutter, viewportSize - gutter]`. This is the clamp shape every
|
|
63
|
+
* anchored surface shared (HkPopover's cross + main axis, HkSelectPanel's
|
|
64
|
+
* top/left): below the gutter when it fits, else pinned to the gutter.
|
|
65
|
+
*/
|
|
66
|
+
export function clampWithGutter(
|
|
67
|
+
value: number,
|
|
68
|
+
size: number,
|
|
69
|
+
viewportSize: number,
|
|
70
|
+
gutter: number,
|
|
71
|
+
): number {
|
|
72
|
+
return Math.max(gutter, Math.min(value, viewportSize - size - gutter));
|
|
73
|
+
}
|
package/src/scale.scss
CHANGED
|
@@ -69,8 +69,25 @@
|
|
|
69
69
|
--z-header: 100;
|
|
70
70
|
--z-sidebar: 900;
|
|
71
71
|
|
|
72
|
+
/* Viewport gutter — the minimum distance every floating layer (tooltip,
|
|
73
|
+
* popover, menu, select popout, modal frame, toasts) keeps from the
|
|
74
|
+
* viewport edge. Anchored popups clamp their position to it; intentional
|
|
75
|
+
* full-bleed surfaces (drawers, bottom sheets) are exempt. 16px on
|
|
76
|
+
* desktop-width viewports, 8px on mobile (runtime/viewportGutter.ts
|
|
77
|
+
* reads the same token so the JS clamps and the CSS caps never drift). */
|
|
78
|
+
--viewport-gutter: 16px;
|
|
79
|
+
|
|
72
80
|
/* Fonts — keep in sync with packages/vue/src/theme/fontContext.ts */
|
|
73
81
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
74
82
|
--font-reading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
75
83
|
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", "PingFang SC", "Microsoft YaHei", monospace;
|
|
76
84
|
}
|
|
85
|
+
|
|
86
|
+
/* Mobile viewports halve the floating-layer gutter. The breakpoint mirrors
|
|
87
|
+
* runtime/useBreakpoint (isMobile = width < 768) so the CSS override and
|
|
88
|
+
* the JS fallback switch on the same line. */
|
|
89
|
+
@media (max-width: 767px) {
|
|
90
|
+
:root {
|
|
91
|
+
--viewport-gutter: 8px;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -71,8 +71,25 @@
|
|
|
71
71
|
--z-header: 100;
|
|
72
72
|
--z-sidebar: 900;
|
|
73
73
|
|
|
74
|
+
/* Viewport gutter — the minimum distance every floating layer (tooltip,
|
|
75
|
+
* popover, menu, select popout, modal frame, toasts) keeps from the
|
|
76
|
+
* viewport edge. Anchored popups clamp their position to it; intentional
|
|
77
|
+
* full-bleed surfaces (drawers, bottom sheets) are exempt. 16px on
|
|
78
|
+
* desktop-width viewports, 8px on mobile (runtime/viewportGutter.ts
|
|
79
|
+
* reads the same token so the JS clamps and the CSS caps never drift). */
|
|
80
|
+
--viewport-gutter: 16px;
|
|
81
|
+
|
|
74
82
|
/* Fonts — keep in sync with packages/vue/src/theme/fontContext.ts */
|
|
75
83
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
76
84
|
--font-reading: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
|
77
85
|
--font-mono: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", "Liberation Mono", "PingFang SC", "Microsoft YaHei", monospace;
|
|
78
86
|
}
|
|
87
|
+
|
|
88
|
+
/* Mobile viewports halve the floating-layer gutter. The breakpoint mirrors
|
|
89
|
+
* runtime/useBreakpoint (isMobile = width < 768) so the CSS override and
|
|
90
|
+
* the JS fallback switch on the same line. */
|
|
91
|
+
@media (max-width: 767px) {
|
|
92
|
+
:root {
|
|
93
|
+
--viewport-gutter: 8px;
|
|
94
|
+
}
|
|
95
|
+
}
|