@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.47.1",
3
+ "version": "0.48.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -9,7 +9,9 @@
9
9
  .hk-blocking-toast-container {
10
10
  position: fixed;
11
11
  top: 4rem;
12
- inset-inline-end: 1rem;
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
- const openRight = r.right + cascadeW <= window.innerWidth - VIEWPORT_PAD;
419
- const left = openRight ? r.right : Math.max(VIEWPORT_PAD, r.left - cascadeW);
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
- const openRight = r.right + gap + cascadeW <= window.innerWidth - VIEWPORT_PAD;
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(VIEWPORT_PAD, r.left - cascadeW - gap);
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(() => resolveModalWidth(props.width));
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("32rem");
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("560px");
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 * 8px));
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
- * VIEWPORT_PAD is 8 (script side). */
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 * 8px);
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 * 8px)` (the anchored panel's
10
- * anti-ratchet clamp, #421). The mobile sheet branch overrides
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
- expect(base).toContain("max-width: calc(100vw - 2 * 8px)");
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 + VIEWPORT_PAD && spaceAbove > spaceBelow) {
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 + VIEWPORT_PAD && spaceBelow > spaceAbove) {
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 + VIEWPORT_PAD && spaceLeft > spaceRight) {
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 + VIEWPORT_PAD && spaceRight > spaceLeft) {
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 = Math.max(VIEWPORT_PAD, Math.min(crossPos, viewportSize - panelSize - VIEWPORT_PAD));
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 = Math.max(VIEWPORT_PAD, Math.min(c.top!, vh - panelRect.height - VIEWPORT_PAD));
397
+ c.top = clampWithGutter(c.top!, panelRect.height, vh, pad);
395
398
  } else if (side === "top") {
396
- c.bottom = Math.max(VIEWPORT_PAD, Math.min(c.bottom ?? 0, vh - panelRect.height - VIEWPORT_PAD));
399
+ c.bottom = clampWithGutter(c.bottom ?? 0, panelRect.height, vh, pad);
397
400
  } else if (side === "right") {
398
- c.left = Math.max(VIEWPORT_PAD, Math.min(c.left!, vw - panelRect.width - VIEWPORT_PAD));
401
+ c.left = clampWithGutter(c.left!, panelRect.width, vw, pad);
399
402
  } else {
400
- c.right = Math.max(VIEWPORT_PAD, Math.min(c.right ?? 0, vw - panelRect.width - VIEWPORT_PAD));
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
- // pad instead of mirroring the overflow to both sides: anchor
328
- // 0..40 → raw left (20 - 180/2) = -70 → clamped to 8.
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("8px");
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 - 8 - 180 = 1012 → pinned at the pad.
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("1012px");
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 pad and the
423
- // bottom edge inside it too — overflow beyond that is the panel's
424
- // own internal scroll, never off-screen geometry.
425
- expect(top).toBeGreaterThanOrEqual(8);
426
- expect(top + 576).toBeLessThanOrEqual(800 - 8);
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 - VIEWPORT_PAD) {
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 < VIEWPORT_PAD) {
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
- const maxTop = Math.max(VIEWPORT_PAD, window.innerHeight - VIEWPORT_PAD - ph);
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
- let left =
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 maxLeft = Math.max(VIEWPORT_PAD, window.innerWidth - VIEWPORT_PAD - pw);
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(left / z)}px`,
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
- inset-inline-end: var(--space-16, 1rem);
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 { tooltipPositionStyle, type TooltipPlacement } from "../runtime/tooltipPosition";
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 tooltipStyle = ref<CSSProperties>({});
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
- tooltipStyle.value = tooltipPositionStyle(rect, props.placement, props.maxWidth);
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
- const popupStyle = computed<CSSProperties>(() => ({
78
- ...tooltipStyle.value,
79
- ...(zIndex.value != null ? { zIndex: zIndex.value } : {}),
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
  >
@@ -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 (tooltipPositionStyle: same placement/zoom math as
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 { tooltipPositionStyle, type TooltipPlacement } from "./tooltipPosition";
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
- Object.assign(state.popup.style, tooltipPositionStyle(
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 { TOOLTIP_GAP_PX, tooltipPositionStyle } from "./tooltipPosition";
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
+ }