@celestia-island/hikari 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/HkAuthMethodList.scss +89 -10
- package/src/components/HkAuthMethodList.test.tsx +33 -21
- package/src/components/HkAuthMethodList.tsx +58 -30
- package/src/components/HkBlockingToast.scss +3 -1
- package/src/components/HkIconButtonGroup.tsx +15 -7
- package/src/components/HkMenu.tsx +9 -6
- package/src/components/HkModal.tsx +17 -1
- package/src/components/HkModal.width.test.tsx +6 -2
- package/src/components/HkPopover.scss +4 -3
- package/src/components/HkPopover.sheet-maxwidth.test.ts +3 -2
- package/src/components/HkPopover.surfacevars.test.ts +3 -1
- package/src/components/HkPopover.tsx +14 -11
- package/src/components/HkSelect.scss +11 -0
- package/src/components/HkSelectPanel.popoutwidth.test.ts +47 -0
- package/src/components/HkSelectPanel.test.tsx +12 -10
- package/src/components/HkSelectPanel.tsx +10 -10
- package/src/components/HkToast.scss +3 -1
- package/src/components/HkTooltip.scss +8 -0
- package/src/components/HkTooltip.test.tsx +12 -0
- package/src/components/HkTooltip.tsx +15 -9
- package/src/runtime/index.ts +10 -0
- package/src/runtime/tooltipBridge.ts +10 -6
- package/src/runtime/tooltipPosition.test.ts +213 -2
- package/src/runtime/tooltipPosition.ts +159 -0
- package/src/runtime/viewportGutter.test.ts +98 -0
- package/src/runtime/viewportGutter.ts +73 -0
- package/src/scale.scss +17 -0
- package/src/styles/theme/scale.scss +17 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
/* Alternative sign-in methods as
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/* Alternative sign-in methods as CENTERED INDEPENDENT framed icon tiles
|
|
2
|
+
* (login card). The block container `.s-auth-methods` and its spacing are
|
|
3
|
+
* owned by HkAuthCard's slot styles (styles/admin-tokens.scss); this file
|
|
4
|
+
* only styles the divider and the tile row the component renders into that
|
|
5
|
+
* slot.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT a button group (2026-09-14 user direction): no shared
|
|
8
|
+
* track, no shared frame — each provider carries its own border so the row
|
|
9
|
+
* stays as wide as its content, centered in the card. */
|
|
6
10
|
|
|
7
11
|
/* "Other ways to sign in" divider row between rules. */
|
|
8
12
|
.s-auth-methods-divider {
|
|
@@ -24,16 +28,91 @@
|
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/* The component's own wrapper is `display: contents` — layout-wise the
|
|
27
|
-
divider and the
|
|
31
|
+
divider and the tile row remain direct children of HkAuthCard's
|
|
28
32
|
`.s-auth-methods` slot container. */
|
|
29
33
|
.s-auth-methods-list {
|
|
30
34
|
display: contents;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
|
-
/* The
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
/* The tile row: fill the card column and center the independent tiles
|
|
38
|
+
inside it. The gap between tiles is the INDEPENDENT-action spacing
|
|
39
|
+
(--space-12) — comfortable air, unlike a group's shoulder-to-shoulder
|
|
40
|
+
track gap. */
|
|
41
|
+
.s-auth-methods .s-auth-methods-tiles {
|
|
42
|
+
display: flex;
|
|
43
|
+
flex-wrap: wrap;
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
gap: var(--space-12, 0.75rem);
|
|
37
47
|
width: 100%;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* The HkTooltip wrapper span is the flex child between tiles — let it
|
|
51
|
+
shrink to the button so the row gap (not the wrapper) spaces them. */
|
|
52
|
+
.s-auth-methods .s-auth-methods-tiles > .hk-tooltip-wrapper {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
flex: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* One provider tile: a framed 44px square (the login-chooser tile size,
|
|
58
|
+
same as the icon group's md item). Each tile owns its whole frame —
|
|
59
|
+
border, hover wash, focus ring — with no track around the row. */
|
|
60
|
+
.s-auth-methods-tile {
|
|
61
|
+
display: inline-flex;
|
|
62
|
+
align-items: center;
|
|
63
|
+
justify-content: center;
|
|
64
|
+
width: 2.75rem;
|
|
65
|
+
height: 2.75rem;
|
|
66
|
+
padding: 0;
|
|
67
|
+
font: inherit;
|
|
68
|
+
color: rgb(var(--color-text, 25 30 40));
|
|
69
|
+
background: rgb(var(--color-surface, 255 255 255) / 55%);
|
|
70
|
+
border: 1px solid rgb(var(--color-border, 222 224 228) / 55%);
|
|
71
|
+
border-radius: var(--radius-md, 8px);
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
outline: none;
|
|
74
|
+
transition-property: background-color, border-color, color, box-shadow, opacity, transform;
|
|
75
|
+
transition-duration: var(--duration-short, 0.15s);
|
|
76
|
+
transition-timing-function: ease;
|
|
77
|
+
|
|
78
|
+
/* A real <button disabled> still matches :hover — guard the dead ones. */
|
|
79
|
+
&:hover:not(:disabled) {
|
|
80
|
+
color: rgb(var(--color-text, 25 30 40));
|
|
81
|
+
background: var(--c-primary-faint, rgb(var(--color-primary, 90 140 255) / 5%));
|
|
82
|
+
border-color: var(--c-primary-medium, rgb(var(--color-primary, 90 140 255) / 25%));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Tactile press feedback — the button-family cadence. */
|
|
86
|
+
&:active:not(:disabled) {
|
|
87
|
+
transform: scale(0.96);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Outset double ring, same grammar as .hk-btn. */
|
|
91
|
+
&:focus-visible {
|
|
92
|
+
box-shadow:
|
|
93
|
+
0 0 0 2px rgb(var(--color-surface, 255 255 255)),
|
|
94
|
+
0 0 0 4px rgb(var(--color-primary, 90 140 255));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&:disabled {
|
|
98
|
+
opacity: 0.5;
|
|
99
|
+
cursor: not-allowed;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.s-auth-methods-tile-icon {
|
|
104
|
+
display: inline-flex;
|
|
105
|
+
align-items: center;
|
|
38
106
|
justify-content: center;
|
|
107
|
+
line-height: 0;
|
|
108
|
+
/* Brand marks (svg/img vnodes) at their intrinsic size; lucide comps
|
|
109
|
+
sized by the host. The box never grows past the square. */
|
|
110
|
+
max-width: 100%;
|
|
111
|
+
max-height: 100%;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.s-auth-methods-tile-initial {
|
|
115
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
line-height: 1;
|
|
39
118
|
}
|
|
@@ -28,27 +28,39 @@ const methods = [
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
describe("HkAuthMethodList", () => {
|
|
31
|
-
it("renders
|
|
31
|
+
it("renders centered independent framed tiles with an optional divider", () => {
|
|
32
32
|
// The card's `methods` slot owns the `.s-auth-methods` container —
|
|
33
|
-
// reproduce that context here (the divider and the
|
|
33
|
+
// reproduce that context here (the divider and the tile row are layout
|
|
34
34
|
// children of it through the component's display:contents wrapper).
|
|
35
35
|
const c = mount(
|
|
36
36
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
|
|
37
37
|
);
|
|
38
38
|
const divider = c.querySelector(".s-auth-methods-divider");
|
|
39
39
|
expect(divider?.textContent).toContain("其他方式登录");
|
|
40
|
-
const
|
|
41
|
-
expect(
|
|
42
|
-
expect(group!.classList.contains("hk-icon-group-buttons")).toBe(true);
|
|
43
|
-
const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
|
|
44
|
-
expect(buttons.length).toBe(2);
|
|
40
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
|
|
41
|
+
expect(tiles.length).toBe(2);
|
|
45
42
|
// Icon-only: the label is the accessible name + tooltip, never visible text.
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
48
|
-
expect(
|
|
43
|
+
expect(tiles[0]!.getAttribute("aria-label")).toBe("GitHub");
|
|
44
|
+
expect(tiles[1]!.getAttribute("aria-label")).toBe("LinuxDo");
|
|
45
|
+
expect(tiles[0]!.textContent).not.toContain("GitHub");
|
|
49
46
|
});
|
|
50
47
|
|
|
51
|
-
it("wraps
|
|
48
|
+
it("never wraps the providers in a button-group track", () => {
|
|
49
|
+
// 2026-09-14 user direction: a shared group frame (one wide bordered
|
|
50
|
+
// track with the icons floating inside) is NOT this component's
|
|
51
|
+
// grammar — every provider is an independent framed tile. A regression
|
|
52
|
+
// back to HkIconButtonGroup (or any .hk-icon-group markup) must fail
|
|
53
|
+
// here, because the button group is reserved for selectors and tight
|
|
54
|
+
// action strips.
|
|
55
|
+
const c = mount(
|
|
56
|
+
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
57
|
+
);
|
|
58
|
+
expect(c.querySelector(".s-auth-methods .hk-icon-group")).toBeNull();
|
|
59
|
+
expect(c.querySelector(".s-auth-methods [role='group']")).toBeNull();
|
|
60
|
+
expect(c.querySelector(".s-auth-methods [role='radiogroup']")).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("wraps every tile in a tooltip carrying the provider label", () => {
|
|
52
64
|
const c = mount(
|
|
53
65
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
54
66
|
);
|
|
@@ -71,12 +83,12 @@ describe("HkAuthMethodList", () => {
|
|
|
71
83
|
},
|
|
72
84
|
})),
|
|
73
85
|
);
|
|
74
|
-
const
|
|
75
|
-
|
|
86
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
|
|
87
|
+
tiles[1]!.click();
|
|
76
88
|
await nextTick();
|
|
77
89
|
// Both rows are asserted so a constant-literal emit cannot ride an
|
|
78
90
|
// accidental fixture coincidence.
|
|
79
|
-
|
|
91
|
+
tiles[0]!.click();
|
|
80
92
|
await nextTick();
|
|
81
93
|
expect(picked).toEqual(["linuxdo", "github"]);
|
|
82
94
|
});
|
|
@@ -95,15 +107,15 @@ describe("HkAuthMethodList", () => {
|
|
|
95
107
|
},
|
|
96
108
|
})),
|
|
97
109
|
);
|
|
98
|
-
const
|
|
99
|
-
expect(
|
|
100
|
-
// The prebuilt brand vnode renders inside the icon
|
|
101
|
-
expect(
|
|
110
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .s-auth-methods-tile");
|
|
111
|
+
expect(tiles.length).toBe(3);
|
|
112
|
+
// The prebuilt brand vnode renders inside the icon box.
|
|
113
|
+
expect(tiles[0]!.querySelector(".brand-gh")).toBeTruthy();
|
|
102
114
|
// A missing icon falls back to the label initial (never an empty box).
|
|
103
|
-
expect(
|
|
115
|
+
expect(tiles[2]!.querySelector<HTMLElement>(".s-auth-methods-tile-initial")?.textContent).toBe("F");
|
|
104
116
|
// Disabled entries render dead and swallow clicks.
|
|
105
|
-
expect(
|
|
106
|
-
|
|
117
|
+
expect(tiles[1]!.disabled).toBe(true);
|
|
118
|
+
tiles[1]!.click();
|
|
107
119
|
await nextTick();
|
|
108
120
|
expect(picked).toBe("");
|
|
109
121
|
});
|
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { defineComponent } from "vue";
|
|
2
|
-
import
|
|
2
|
+
import HkTooltip from "./HkTooltip";
|
|
3
3
|
import "./HkAuthMethodList.scss";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* HkAuthMethodList — the auth card's third-party sign-in block (the
|
|
7
7
|
* "other ways to sign in" row under the credentials form).
|
|
8
8
|
*
|
|
9
|
-
* Since 2026-09-
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* (
|
|
16
|
-
* deployment offers several providers. The `select` event carries the
|
|
17
|
-
* provider `key` exactly as before, so consumers swap rendering
|
|
18
|
-
* without touching their OAuth flow.
|
|
9
|
+
* Since 2026-09-14 (user direction) the block renders as CENTERED
|
|
10
|
+
* INDEPENDENT icon tiles: every provider is its own framed 44px icon
|
|
11
|
+
* button with its own tooltip — NOT a button group. A group track (one
|
|
12
|
+
* shared frame stretched across the card with the icons floating in
|
|
13
|
+
* the middle) reads as an oversized empty box; the button-group
|
|
14
|
+
* component is reserved for its two fundamental jobs — a selector
|
|
15
|
+
* (single/multiple) and a tight action strip (see HkIconButtonGroup).
|
|
19
16
|
*
|
|
20
|
-
*
|
|
17
|
+
* Provider identity is revealed on hover: each tile wraps itself in an
|
|
18
|
+
* HkTooltip carrying the provider label, which matches the platform
|
|
19
|
+
* login-chooser pattern (Windows/macOS account tiles) and keeps the
|
|
20
|
+
* card compact when a deployment offers several providers. The
|
|
21
|
+
* `select` event carries the provider `key` exactly as before, so
|
|
22
|
+
* consumers swap rendering without touching their OAuth flow.
|
|
23
|
+
*
|
|
24
|
+
* The wrapper stays `display: contents`: the divider and the tile row
|
|
21
25
|
* remain layout children of HkAuthCard's `.s-auth-methods` slot
|
|
22
26
|
* container (it owns the side padding and vertical rhythm).
|
|
23
27
|
*/
|
|
@@ -35,11 +39,11 @@ export default defineComponent({
|
|
|
35
39
|
/** Accessible name + tooltip text (the hover reveal). */
|
|
36
40
|
label: string;
|
|
37
41
|
/** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
42
|
+
* framed tile. Typed loose on purpose (same as HkIconButtonGroup
|
|
43
|
+
* options): hosts materialize hikari against their own vue store,
|
|
44
|
+
* and a hard VNode type breaks typecheck whenever the host's vue
|
|
45
|
+
* minor differs. A missing icon falls back to the label's
|
|
46
|
+
* initial. */
|
|
43
47
|
icon?: unknown;
|
|
44
48
|
disabled?: boolean;
|
|
45
49
|
}>,
|
|
@@ -47,7 +51,7 @@ export default defineComponent({
|
|
|
47
51
|
},
|
|
48
52
|
},
|
|
49
53
|
emits: {
|
|
50
|
-
/** A provider
|
|
54
|
+
/** A provider tile was clicked. */
|
|
51
55
|
select: (_key: string) => true,
|
|
52
56
|
},
|
|
53
57
|
setup(props, { emit }) {
|
|
@@ -58,18 +62,42 @@ export default defineComponent({
|
|
|
58
62
|
<span>{props.divider}</span>
|
|
59
63
|
</div>
|
|
60
64
|
)}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
{/* A plain row, deliberately NOT role="group": every tile is an
|
|
66
|
+
independent action (its own frame, its own tooltip), and the
|
|
67
|
+
divider above names the section for sighted users. Group
|
|
68
|
+
semantics belong to the selector/tight-strip components. */}
|
|
69
|
+
<div class="s-auth-methods-tiles">
|
|
70
|
+
{props.methods.map((method) => {
|
|
71
|
+
const tile = (
|
|
72
|
+
<button
|
|
73
|
+
key={method.key}
|
|
74
|
+
type="button"
|
|
75
|
+
class="s-auth-methods-tile"
|
|
76
|
+
data-key={method.key}
|
|
77
|
+
aria-label={method.label}
|
|
78
|
+
title={undefined} // the HkTooltip popup owns the hover text
|
|
79
|
+
disabled={method.disabled === true}
|
|
80
|
+
onClick={() => emit("select", method.key)}
|
|
81
|
+
>
|
|
82
|
+
<span class="s-auth-methods-tile-icon" aria-hidden="true">
|
|
83
|
+
{method.icon != null
|
|
84
|
+
? method.icon
|
|
85
|
+
: (
|
|
86
|
+
<span class="s-auth-methods-tile-initial">
|
|
87
|
+
{method.label.charAt(0).toUpperCase()}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
return (
|
|
95
|
+
<HkTooltip key={method.key} text={method.label} placement="top" delay={300}>
|
|
96
|
+
{tile}
|
|
97
|
+
</HkTooltip>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
73
101
|
</div>
|
|
74
102
|
);
|
|
75
103
|
},
|
|
@@ -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;
|
|
@@ -25,13 +25,21 @@ export interface HkIconButtonGroupOption {
|
|
|
25
25
|
* HkIconButtonGroup — the icon-only variant of the button-group family
|
|
26
26
|
* (the same centered strip grammar as HkTabs, minus the text).
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* - "single"
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* The group has exactly TWO fundamental jobs (2026-09-14 user direction
|
|
29
|
+
* — a group is a semantic component, not a generic box to throw buttons
|
|
30
|
+
* into), carried by three working modes:
|
|
31
|
+
* - a SELECTOR: "single" (radiogroup: one active key via v-model,
|
|
32
|
+
* re-clicking the active key is a no-op — radio semantics) or
|
|
33
|
+
* "multiple" (toggle set: v-model is a string[] of active keys,
|
|
34
|
+
* every item carries aria-pressed)
|
|
35
|
+
* - a TIGHT ACTION STRIP: "buttons" (no selection state) — related
|
|
36
|
+
* actions packed shoulder-to-shoulder in ONE shared track, the
|
|
37
|
+
* toolbar grammar
|
|
38
|
+
*
|
|
39
|
+
* Anything else is NOT a group: independent actions that merely live on
|
|
40
|
+
* the same row (e.g. a login card's third-party provider tiles) render
|
|
41
|
+
* as separate framed buttons with comfortable spacing — see
|
|
42
|
+
* HkAuthMethodList for the reference pattern.
|
|
35
43
|
*
|
|
36
44
|
* Every item is icon-only and rides one size taller than a standard
|
|
37
45
|
* icon button (md = 44px vs the 32px icon button / 40px text button) —
|
|
@@ -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
|