@celestia-island/hikari 0.45.8 → 0.46.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/DemoApp.tsx +37 -1
- package/src/components/HkAuthMethodList.scss +13 -39
- package/src/components/HkAuthMethodList.test.tsx +55 -73
- package/src/components/HkAuthMethodList.tsx +40 -149
- package/src/components/HkCrossfade.scss +61 -0
- package/src/components/HkCrossfade.test.tsx +270 -0
- package/src/components/HkCrossfade.tsx +197 -0
- package/src/components/HkIconButtonGroup.scss +104 -0
- package/src/components/HkIconButtonGroup.test.tsx +182 -0
- package/src/components/HkIconButtonGroup.tsx +152 -0
- package/src/components/HkTooltip.tsx +7 -42
- package/src/index.ts +6 -0
- package/src/runtime/imageFallback.scss +13 -0
- package/src/runtime/imageFallback.test.ts +149 -0
- package/src/runtime/imageFallback.ts +125 -0
- package/src/runtime/index.ts +13 -0
- package/src/runtime/tooltipBridge.test.ts +213 -0
- package/src/runtime/tooltipBridge.ts +292 -0
- package/src/runtime/tooltipPosition.test.ts +41 -0
- package/src/runtime/tooltipPosition.ts +61 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { computed, defineComponent, ref } from "vue";
|
|
|
5
5
|
import { ChevronDown, ChevronUp, PackageOpen } from "lucide-vue-next";
|
|
6
6
|
import { HIKARI_FONT_MONO } from "./theme/fontContext";
|
|
7
7
|
import {
|
|
8
|
-
HButton, HIconButton, HTooltip, HBadge, HTag, HIcon, HSpinner,
|
|
8
|
+
HButton, HIconButton, HIconButtonGroup, HTooltip, HBadge, HTag, HIcon, HSpinner,
|
|
9
9
|
HProgressBar, HProgressRing, HGaugeRing,
|
|
10
10
|
HInput, HSearchInput, HNumberInput, HPasswordInput, HTextarea,
|
|
11
11
|
HFileField,
|
|
@@ -158,6 +158,8 @@ export default defineComponent({
|
|
|
158
158
|
: panel.value.checked.filter((k) => k !== key);
|
|
159
159
|
}
|
|
160
160
|
const tabs = ref({ active: "tab1", items: [{ key: "tab1", label: "Overview" }, { key: "tab2", label: "Details" }, { key: "tab3", label: "Settings" }] });
|
|
161
|
+
const iconGroupPick = ref<string | null>("totp");
|
|
162
|
+
const iconGroupMulti = ref<string[]>(["flag"]);
|
|
161
163
|
const mediaSliderRatio = ref(0.42);
|
|
162
164
|
const demoAudioSrc = makeSilentWav();
|
|
163
165
|
const demoImageSrc =
|
|
@@ -271,6 +273,40 @@ export default defineComponent({
|
|
|
271
273
|
</div>
|
|
272
274
|
</section>
|
|
273
275
|
|
|
276
|
+
<section>
|
|
277
|
+
<h2>HIconButtonGroup</h2>
|
|
278
|
+
<div class="row" style={{ flexDirection: "column", gap: "12px", alignItems: "flex-start" }}>
|
|
279
|
+
<HIconButtonGroup
|
|
280
|
+
mode="single"
|
|
281
|
+
modelValue={iconGroupPick.value}
|
|
282
|
+
onUpdate:modelValue={(v: string | string[]) => (iconGroupPick.value = v as string)}
|
|
283
|
+
options={[
|
|
284
|
+
{ key: "totp", label: "验证器 App (TOTP)", icon: <HIcon name="settings" size={20} /> },
|
|
285
|
+
{ key: "passkey", label: "Passkey", icon: <HIcon name="key" size={20} /> },
|
|
286
|
+
{ key: "sms", label: "短信验证码", icon: <HIcon name="send" size={20} /> },
|
|
287
|
+
]}
|
|
288
|
+
/>
|
|
289
|
+
<HIconButtonGroup
|
|
290
|
+
mode="buttons"
|
|
291
|
+
size="sm"
|
|
292
|
+
options={[
|
|
293
|
+
{ key: "github", label: "GitHub" },
|
|
294
|
+
{ key: "linuxdo", label: "LinuxDo" },
|
|
295
|
+
]}
|
|
296
|
+
/>
|
|
297
|
+
<HIconButtonGroup
|
|
298
|
+
mode="multiple"
|
|
299
|
+
modelValue={iconGroupMulti.value}
|
|
300
|
+
onUpdate:modelValue={(v: string | string[]) => (iconGroupMulti.value = v as string[])}
|
|
301
|
+
options={[
|
|
302
|
+
{ key: "alarm", label: "Alarm", icon: <HIcon name="bell" size={20} /> },
|
|
303
|
+
{ key: "star", label: "Star", icon: <HIcon name="star" size={20} />, disabled: true },
|
|
304
|
+
{ key: "flag", label: "Flag", icon: <HIcon name="flag" size={20} /> },
|
|
305
|
+
]}
|
|
306
|
+
/>
|
|
307
|
+
</div>
|
|
308
|
+
</section>
|
|
309
|
+
|
|
274
310
|
<section>
|
|
275
311
|
<h2>HTooltip</h2>
|
|
276
312
|
<div class="row">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
/*
|
|
2
|
-
container `.s-auth-methods` and its spacing are owned by
|
|
3
|
-
slot styles (styles/admin-tokens.scss); this file only
|
|
4
|
-
divider and the
|
|
1
|
+
/* Alternative sign-in methods as an icon button group (login card). The
|
|
2
|
+
block container `.s-auth-methods` and its spacing are owned by
|
|
3
|
+
HkAuthCard's slot styles (styles/admin-tokens.scss); this file only
|
|
4
|
+
styles the divider and the icon group row the component renders into
|
|
5
|
+
that slot. */
|
|
5
6
|
|
|
6
7
|
/* "Other ways to sign in" divider row between rules. */
|
|
7
8
|
.s-auth-methods-divider {
|
|
@@ -22,44 +23,17 @@
|
|
|
22
23
|
background: var(--hi-color-border, #646464);
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
/* The component's own wrapper is `display: contents`
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
/* The component's own wrapper is `display: contents` — layout-wise the
|
|
27
|
+
divider and the group row remain direct children of HkAuthCard's
|
|
28
|
+
`.s-auth-methods` slot container. */
|
|
28
29
|
.s-auth-methods-list {
|
|
29
30
|
display: contents;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
/*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
is display:contents).
|
|
38
|
-
The label column width is MEASURED by the component (widest label text
|
|
39
|
-
+ 1px headroom, published as `--auth-methods-label-width` on its
|
|
40
|
-
wrapper, inherited into the rows); the `max-content` fallback only
|
|
41
|
-
covers the pre-measure / un-measurable frame, and apps may still
|
|
42
|
-
override the var explicitly. Labels are centered within the shared
|
|
43
|
-
column so every row's text sits on the same vertical center line. */
|
|
44
|
-
.s-auth-methods .hk-btn .s-auth-methods-icon {
|
|
45
|
-
width: 20px;
|
|
46
|
-
flex: none;
|
|
47
|
-
display: inline-flex;
|
|
48
|
-
align-items: center;
|
|
33
|
+
/* The icon button group row: fill the card column and center the
|
|
34
|
+
provider tiles inside it (descendant match off `.s-auth-methods` —
|
|
35
|
+
the wrapper generates no box of its own). */
|
|
36
|
+
.s-auth-methods .hk-icon-group.s-auth-methods-group {
|
|
37
|
+
width: 100%;
|
|
49
38
|
justify-content: center;
|
|
50
39
|
}
|
|
51
|
-
|
|
52
|
-
.s-auth-methods .hk-btn .s-auth-methods-label {
|
|
53
|
-
width: var(--auth-methods-label-width, max-content);
|
|
54
|
-
/* Hardened floor: even if the published column were ever stale or too
|
|
55
|
-
narrow (engine DPR / zoom mismatch), the label can never be clipped —
|
|
56
|
-
the row just widens to its own text. When the measurement is correct
|
|
57
|
-
the var already exceeds every label's natural width, so the floor is
|
|
58
|
-
inert and alignment is preserved. */
|
|
59
|
-
min-width: max-content;
|
|
60
|
-
flex: none;
|
|
61
|
-
text-align: center;
|
|
62
|
-
white-space: nowrap;
|
|
63
|
-
overflow: hidden;
|
|
64
|
-
text-overflow: ellipsis;
|
|
65
|
-
}
|
|
@@ -19,6 +19,7 @@ afterEach(() => {
|
|
|
19
19
|
app.unmount();
|
|
20
20
|
container.remove();
|
|
21
21
|
}
|
|
22
|
+
for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
|
|
22
23
|
});
|
|
23
24
|
|
|
24
25
|
const methods = [
|
|
@@ -27,102 +28,83 @@ const methods = [
|
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
describe("HkAuthMethodList", () => {
|
|
30
|
-
it("renders
|
|
31
|
+
it("renders the provider row as an icon button group with an optional divider", () => {
|
|
31
32
|
// The card's `methods` slot owns the `.s-auth-methods` container —
|
|
32
|
-
// reproduce that context here (the
|
|
33
|
-
// through the component's display:contents wrapper).
|
|
33
|
+
// reproduce that context here (the divider and the group row are layout
|
|
34
|
+
// children of it through the component's display:contents wrapper).
|
|
34
35
|
const c = mount(
|
|
35
36
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { divider: "其他方式登录", methods })),
|
|
36
37
|
);
|
|
37
38
|
const divider = c.querySelector(".s-auth-methods-divider");
|
|
38
39
|
expect(divider?.textContent).toContain("其他方式登录");
|
|
39
|
-
const
|
|
40
|
+
const group = c.querySelector<HTMLElement>(".s-auth-methods .hk-icon-group");
|
|
41
|
+
expect(group).toBeTruthy();
|
|
42
|
+
expect(group!.classList.contains("hk-icon-group-buttons")).toBe(true);
|
|
43
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
|
|
40
44
|
expect(buttons.length).toBe(2);
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
// Icon-only: the label is the accessible name + tooltip, never visible text.
|
|
46
|
+
expect(buttons[0]!.getAttribute("aria-label")).toBe("GitHub");
|
|
47
|
+
expect(buttons[1]!.getAttribute("aria-label")).toBe("LinuxDo");
|
|
48
|
+
expect(buttons[0]!.textContent).not.toContain("GitHub");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("wraps every item in a tooltip carrying the provider label", () => {
|
|
52
|
+
const c = mount(
|
|
53
|
+
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })),
|
|
43
54
|
);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
const wrappers = c.querySelectorAll(".hk-tooltip-wrapper");
|
|
56
|
+
expect(wrappers.length).toBe(2);
|
|
57
|
+
// The teleported popups exist with the label text (hidden until hover).
|
|
58
|
+
const popups = [...document.querySelectorAll(".hk-tooltip-popup .hk-tooltip-content")]
|
|
59
|
+
.map((el) => el.textContent);
|
|
60
|
+
expect(popups).toContain("GitHub");
|
|
61
|
+
expect(popups).toContain("LinuxDo");
|
|
49
62
|
});
|
|
50
63
|
|
|
51
64
|
it("emits select with the method key on click", async () => {
|
|
52
|
-
|
|
65
|
+
const picked: string[] = [];
|
|
53
66
|
const c = mount(
|
|
54
67
|
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, {
|
|
55
68
|
methods,
|
|
56
69
|
onSelect: (key: string) => {
|
|
57
|
-
picked
|
|
70
|
+
picked.push(key);
|
|
58
71
|
},
|
|
59
72
|
})),
|
|
60
73
|
);
|
|
61
|
-
const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-
|
|
74
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
|
|
62
75
|
buttons[1]!.click();
|
|
63
76
|
await nextTick();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
it("publishes the widest label text plus a 1px headroom as the label-column custom property", async () => {
|
|
68
|
-
const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
|
|
69
|
-
await nextTick();
|
|
70
|
-
const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
|
|
71
|
-
expect(wrapper).toBeTruthy();
|
|
72
|
-
// Layout engines in test DOM report zero text metrics; stub a fractional
|
|
73
|
-
// getBoundingClientRect width on the label spans so the widest-wins pair
|
|
74
|
-
// (LinuxDo wider) exercises the ceil+1 headroom instead of the integer
|
|
75
|
-
// scrollWidth that would previously round a fraction and clip the widest.
|
|
76
|
-
const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
|
|
77
|
-
expect(spans.length).toBe(2);
|
|
78
|
-
// 61.4 ceil→62 (+1→63) and a shorter 58.0: the published column must
|
|
79
|
-
// exceed BOTH so the widest label can never sit on the clip boundary.
|
|
80
|
-
Object.defineProperty(spans[0]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 58 }) });
|
|
81
|
-
Object.defineProperty(spans[1]!, "getBoundingClientRect", { configurable: true, value: () => ({ width: 61.4 }) });
|
|
82
|
-
await nextTick();
|
|
83
|
-
// Re-run measurement via a re-mount-less route: the component exposes
|
|
84
|
-
// measure() — reach it through the rendered component instance.
|
|
85
|
-
const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
|
|
86
|
-
.__vueParentComponent?.exposed;
|
|
87
|
-
expect(typeof instance?.measure).toBe("function");
|
|
88
|
-
(instance!.measure as () => void)();
|
|
89
|
-
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("publishes a width in the column's own units inside a scaled root", async () => {
|
|
93
|
-
// The labels are measured where they are DRAWN while the custom property
|
|
94
|
-
// is a CSS length the column is laid out with: in a scaled or zoomed root
|
|
95
|
-
// the published column would come out that many times too wide.
|
|
96
|
-
const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
|
|
77
|
+
// Both rows are asserted so a constant-literal emit cannot ride an
|
|
78
|
+
// accidental fixture coincidence.
|
|
79
|
+
buttons[0]!.click();
|
|
97
80
|
await nextTick();
|
|
98
|
-
|
|
99
|
-
const spans = [...wrapper.querySelectorAll<HTMLElement>(".s-auth-methods-label")];
|
|
100
|
-
// The scale has to come from a LABEL: the list is `display: contents` and
|
|
101
|
-
// generates no box at all (Blink reports offsetWidth 0 and a 0x0 rect), so
|
|
102
|
-
// stubbing a box on the list would only ever be green by accident.
|
|
103
|
-
for (const [i, drawn] of [58, 61.4].entries()) {
|
|
104
|
-
Object.defineProperty(spans[i]!, "getBoundingClientRect", {
|
|
105
|
-
configurable: true,
|
|
106
|
-
value: () => ({ left: 0, top: 0, right: drawn * 2, bottom: 40, width: drawn * 2, height: 40, x: 0, y: 0 }) as DOMRect,
|
|
107
|
-
});
|
|
108
|
-
// Laid out at half: the label is drawn twice the size it is laid out at.
|
|
109
|
-
Object.defineProperty(spans[i]!, "offsetWidth", { configurable: true, get: () => drawn });
|
|
110
|
-
Object.defineProperty(spans[i]!, "offsetHeight", { configurable: true, get: () => 20 });
|
|
111
|
-
}
|
|
112
|
-
const instance = (wrapper as unknown as { __vueParentComponent?: { exposed?: Record<string, unknown> } })
|
|
113
|
-
.__vueParentComponent?.exposed;
|
|
114
|
-
(instance!.measure as () => void)();
|
|
115
|
-
// 122.8 drawn are 61.4 of the column's own: ceil 62, plus the 1px headroom.
|
|
116
|
-
// Reading the scale off the LIST instead — the shape this shipped with —
|
|
117
|
-
// yields 1 for a `display: contents` element and publishes the drawn 124px.
|
|
118
|
-
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("63px");
|
|
81
|
+
expect(picked).toEqual(["linuxdo", "github"]);
|
|
119
82
|
});
|
|
120
83
|
|
|
121
|
-
it("
|
|
122
|
-
|
|
84
|
+
it("renders the icon vnode and honors disabled entries", async () => {
|
|
85
|
+
let picked = "";
|
|
86
|
+
const c = mount(
|
|
87
|
+
h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, {
|
|
88
|
+
methods: [
|
|
89
|
+
{ key: "gh", label: "GitHub", icon: h("span", { class: "brand-gh" }) },
|
|
90
|
+
{ key: "ld", label: "LinuxDo", disabled: true },
|
|
91
|
+
{ key: "fs", label: "Feishu" },
|
|
92
|
+
],
|
|
93
|
+
onSelect: (key: string) => {
|
|
94
|
+
picked = key;
|
|
95
|
+
},
|
|
96
|
+
})),
|
|
97
|
+
);
|
|
98
|
+
const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
|
|
99
|
+
expect(buttons.length).toBe(3);
|
|
100
|
+
// The prebuilt brand vnode renders inside the icon column.
|
|
101
|
+
expect(buttons[0]!.querySelector(".brand-gh")).toBeTruthy();
|
|
102
|
+
// A missing icon falls back to the label initial (never an empty box).
|
|
103
|
+
expect(buttons[2]!.querySelector<HTMLElement>(".hk-icon-group-item-initial")?.textContent).toBe("F");
|
|
104
|
+
// Disabled entries render dead and swallow clicks.
|
|
105
|
+
expect(buttons[1]!.disabled).toBe(true);
|
|
106
|
+
buttons[1]!.click();
|
|
123
107
|
await nextTick();
|
|
124
|
-
|
|
125
|
-
// Zero text metrics (no layout) must not publish a 0px or a 1px column.
|
|
126
|
-
expect(wrapper.style.getPropertyValue("--auth-methods-label-width")).toBe("");
|
|
108
|
+
expect(picked).toBe("");
|
|
127
109
|
});
|
|
128
110
|
});
|
|
@@ -1,37 +1,30 @@
|
|
|
1
|
-
import { defineComponent
|
|
2
|
-
import
|
|
3
|
-
import { drawnScale } from "../composables/layoutGeometry";
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
import HkIconButtonGroup from "./HkIconButtonGroup";
|
|
4
3
|
import "./HkAuthMethodList.scss";
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
|
-
* HkAuthMethodList —
|
|
8
|
-
*
|
|
6
|
+
* HkAuthMethodList — the auth card's third-party sign-in block (the
|
|
7
|
+
* "other ways to sign in" row under the credentials form).
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* Since 2026-09-13 (user direction) the block renders as the ICON
|
|
10
|
+
* BUTTON GROUP by default: one centered `HkIconButtonGroup` row of
|
|
11
|
+
* icon-only provider buttons (mode "buttons", size md) instead of the
|
|
12
|
+
* stacked full-width [icon | label] buttons. Provider identity is
|
|
13
|
+
* revealed on hover — every item wraps itself in an HkTooltip with the
|
|
14
|
+
* provider label — which matches the platform login-chooser pattern
|
|
15
|
+
* (Windows/macOS account tiles) and keeps the card compact when a
|
|
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.
|
|
15
19
|
*
|
|
16
|
-
* The
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `--auth-methods-label-width` on its wrapper, so the aligned text region
|
|
20
|
-
* hugs the longest label instead of a blind hardcoded width. The measured
|
|
21
|
-
* fraction is rounded UP with a 1px margin — the column is always strictly
|
|
22
|
-
* wider than the longest label, so the text can never overflow into an
|
|
23
|
-
* ellipsis. Labels are centered within that shared column, keeping every
|
|
24
|
-
* row's text on the same vertical center line. Consumers can still
|
|
25
|
-
* override the var explicitly.
|
|
26
|
-
*
|
|
27
|
-
* The wrapper is `display: contents`: the component keeps a DOM node for
|
|
28
|
-
* measurement and CSS-var inheritance while the buttons remain direct
|
|
29
|
-
* layout children of HkAuthCard's `.s-auth-methods` slot container.
|
|
20
|
+
* The wrapper stays `display: contents`: the divider and the group row
|
|
21
|
+
* remain layout children of HkAuthCard's `.s-auth-methods` slot
|
|
22
|
+
* container (it owns the side padding and vertical rhythm).
|
|
30
23
|
*/
|
|
31
24
|
export default defineComponent({
|
|
32
25
|
name: "HkAuthMethodList",
|
|
33
26
|
props: {
|
|
34
|
-
/** Optional divider text rendered above the
|
|
27
|
+
/** Optional divider text rendered above the row, e.g. "其他方式登录".
|
|
35
28
|
* Consumer-localized on purpose: hikari ships no dictionary dependency
|
|
36
29
|
* onto hosts here. */
|
|
37
30
|
divider: { type: String, default: "" },
|
|
@@ -39,13 +32,14 @@ export default defineComponent({
|
|
|
39
32
|
methods: {
|
|
40
33
|
type: Array as unknown as () => Array<{
|
|
41
34
|
key: string;
|
|
35
|
+
/** Accessible name + tooltip text (the hover reveal). */
|
|
42
36
|
label: string;
|
|
43
|
-
/** Prebuilt icon vnode (brand SVG, <img>, …)
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
37
|
+
/** Prebuilt icon vnode (brand SVG, <img>, …) rendered inside the
|
|
38
|
+
* icon-only button. Typed loose on purpose (same as
|
|
39
|
+
* HkIconButtonGroup options): hosts materialize hikari against
|
|
40
|
+
* their own vue store, and a hard VNode type breaks typecheck
|
|
41
|
+
* whenever the host's vue minor differs. A missing icon falls
|
|
42
|
+
* back to the label's initial. */
|
|
49
43
|
icon?: unknown;
|
|
50
44
|
disabled?: boolean;
|
|
51
45
|
}>,
|
|
@@ -53,132 +47,29 @@ export default defineComponent({
|
|
|
53
47
|
},
|
|
54
48
|
},
|
|
55
49
|
emits: {
|
|
56
|
-
/** A
|
|
50
|
+
/** A provider button was clicked. */
|
|
57
51
|
select: (_key: string) => true,
|
|
58
52
|
},
|
|
59
|
-
setup(props, { emit
|
|
60
|
-
const listRef = ref<HTMLDivElement | null>(null);
|
|
61
|
-
|
|
62
|
-
let resizeObserver: ResizeObserver | undefined;
|
|
63
|
-
const onResize = () => void nextTick(measure);
|
|
64
|
-
const onFontsReady = () => measure();
|
|
65
|
-
|
|
66
|
-
/** Measure the widest label text and publish it as the label-column
|
|
67
|
-
* width custom property. Each span is momentarily set to
|
|
68
|
-
* `max-content` while reading: a plain `scrollWidth` on the fixed
|
|
69
|
-
* column would report `max(textWidth, columnWidth)` and the column
|
|
70
|
-
* could never shrink below its fallback. All reads happen inside one
|
|
71
|
-
* synchronous task, so no intermediate paint is observable.
|
|
72
|
-
*
|
|
73
|
-
* Precision: the widest width is read as a fractional
|
|
74
|
-
* `getBoundingClientRect()` (not the integer `scrollWidth`) and
|
|
75
|
-
* rounded UP with a 1px margin before publishing. An integer read can
|
|
76
|
-
* round the longest label DOWN (some engines floor it), pinning its
|
|
77
|
-
* column exactly on the glyph boundary — then any sub-pixel
|
|
78
|
-
* font-metric difference (zoom, DPR, engine) makes that one label
|
|
79
|
-
* overflow and render an ellipsis while the shorter rows stay fine.
|
|
80
|
-
* The headroom keeps the longest label strictly inside its column. */
|
|
81
|
-
function measure() {
|
|
82
|
-
const root = listRef.value;
|
|
83
|
-
if (!root) return;
|
|
84
|
-
let widest = 0;
|
|
85
|
-
// The labels are measured where they are DRAWN, and the width is
|
|
86
|
-
// published as a CSS length the column is laid out with: in a scaled or
|
|
87
|
-
// zoomed root the two differ by the scale of the space, so the column
|
|
88
|
-
// would come out that many times too wide (see `layoutGeometry`).
|
|
89
|
-
//
|
|
90
|
-
// The scale is read from the LABEL — the element the box was measured
|
|
91
|
-
// on. Reading it from the list itself cannot work: the list is
|
|
92
|
-
// `display: contents`, so it generates no box at all (measured in Blink:
|
|
93
|
-
// `offsetWidth` 0, a 0x0 rect, computed width `auto`), and every reader
|
|
94
|
-
// of a box reports nothing to divide by.
|
|
95
|
-
for (const label of root.querySelectorAll<HTMLElement>(".s-auth-methods-label")) {
|
|
96
|
-
const previous = label.style.width;
|
|
97
|
-
label.style.width = "max-content";
|
|
98
|
-
const drawn = label.getBoundingClientRect().width;
|
|
99
|
-
const scale = drawnScale(label);
|
|
100
|
-
const factor = Number.isFinite(scale.x) && scale.x > 0 ? scale.x : 1;
|
|
101
|
-
// `offsetWidth` is the laid-out fallback — already in the units the
|
|
102
|
-
// published length is used in, so it needs no division.
|
|
103
|
-
widest = Math.max(widest, factor > 0 ? drawn / factor : label.offsetWidth);
|
|
104
|
-
label.style.width = previous;
|
|
105
|
-
}
|
|
106
|
-
// Guard: only publish when real text metrics were observed, so a
|
|
107
|
-
// hidden / zero-size frame keeps the CSS `max-content` fallback
|
|
108
|
-
// instead of collapsing the column to a clipping 1px.
|
|
109
|
-
if (widest > 0) {
|
|
110
|
-
root.style.setProperty("--auth-methods-label-width", `${Math.ceil(widest) + 1}px`);
|
|
111
|
-
}
|
|
112
|
-
// Keep observing every rendered row so a layout change (the panel
|
|
113
|
-
// becoming visible, a container width change) re-measures. Rows are
|
|
114
|
-
// full-width `.hk-btn-block`, so publishing the column width never
|
|
115
|
-
// resizes them (no feedback loop into the observer).
|
|
116
|
-
if (resizeObserver) {
|
|
117
|
-
for (const button of root.querySelectorAll<HTMLElement>(".hk-btn")) {
|
|
118
|
-
resizeObserver.observe(button);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
onMounted(() => {
|
|
124
|
-
void nextTick(measure);
|
|
125
|
-
// Webfonts change text metrics after first paint; re-measure when
|
|
126
|
-
// they finish loading (no-op in environments without document.fonts).
|
|
127
|
-
if (typeof document !== "undefined" && document.fonts) {
|
|
128
|
-
void document.fonts.ready.then(measure).catch(() => {});
|
|
129
|
-
document.fonts.addEventListener?.("loadingdone", onFontsReady);
|
|
130
|
-
}
|
|
131
|
-
// Browser zoom and container-width changes reflow the text; without a
|
|
132
|
-
// re-measure the published column goes stale and the longest label
|
|
133
|
-
// overflows into the ellipsis. `resize` covers page zoom; the
|
|
134
|
-
// ResizeObserver covers the panel appearing / the container resizing.
|
|
135
|
-
if (typeof window !== "undefined") {
|
|
136
|
-
window.addEventListener("resize", onResize);
|
|
137
|
-
if (!resizeObserver && typeof ResizeObserver !== "undefined") {
|
|
138
|
-
resizeObserver = new ResizeObserver(onResize);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
onBeforeUnmount(() => {
|
|
143
|
-
listRef.value?.style.removeProperty("--auth-methods-label-width");
|
|
144
|
-
resizeObserver?.disconnect();
|
|
145
|
-
resizeObserver = undefined;
|
|
146
|
-
if (typeof window !== "undefined") {
|
|
147
|
-
window.removeEventListener("resize", onResize);
|
|
148
|
-
}
|
|
149
|
-
if (typeof document !== "undefined" && document.fonts) {
|
|
150
|
-
document.fonts.removeEventListener?.("loadingdone", onFontsReady);
|
|
151
|
-
}
|
|
152
|
-
});
|
|
153
|
-
watch(
|
|
154
|
-
() => props.methods.map((method) => method.label).join("\u0000"),
|
|
155
|
-
() => {
|
|
156
|
-
void nextTick(measure);
|
|
157
|
-
},
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
expose({ measure });
|
|
161
|
-
|
|
53
|
+
setup(props, { emit }) {
|
|
162
54
|
return () => (
|
|
163
|
-
<div class="s-auth-methods-list"
|
|
55
|
+
<div class="s-auth-methods-list">
|
|
164
56
|
{props.divider && (
|
|
165
57
|
<div class="s-auth-methods-divider">
|
|
166
58
|
<span>{props.divider}</span>
|
|
167
59
|
</div>
|
|
168
60
|
)}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
))}
|
|
61
|
+
<HkIconButtonGroup
|
|
62
|
+
class="s-auth-methods-group"
|
|
63
|
+
mode="buttons"
|
|
64
|
+
size="md"
|
|
65
|
+
options={props.methods.map((method) => ({
|
|
66
|
+
key: method.key,
|
|
67
|
+
label: method.label,
|
|
68
|
+
icon: method.icon,
|
|
69
|
+
disabled: method.disabled === true,
|
|
70
|
+
}))}
|
|
71
|
+
onSelect={(key: string) => emit("select", key)}
|
|
72
|
+
/>
|
|
182
73
|
</div>
|
|
183
74
|
);
|
|
184
75
|
},
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* HkCrossfade — same-position simultaneous dissolve host.
|
|
2
|
+
*
|
|
3
|
+
* Geometry: the host is a one-cell grid and every item occupies THAT cell
|
|
4
|
+
* (grid-area: 1/1). During a swap the leaving item keeps the cell, so old
|
|
5
|
+
* and new stack in the SAME box: no exit blank phase, no side-by-side
|
|
6
|
+
* reflow, and the host stays as tall as the taller of the two items until
|
|
7
|
+
* the leave completes — variable-height content never collapses mid-
|
|
8
|
+
* dissolve. This is the height-safe generalization of HkPickerPane's fixed
|
|
9
|
+
* stage + absolute leave (no known stage size needed, so whole pages can
|
|
10
|
+
* swap through it).
|
|
11
|
+
*
|
|
12
|
+
* Consumers size the host from the outside (width/height/flex are theirs);
|
|
13
|
+
* the component only guarantees the stacking geometry and the dissolve.
|
|
14
|
+
*/
|
|
15
|
+
.hk-crossfade {
|
|
16
|
+
display: grid;
|
|
17
|
+
|
|
18
|
+
> .hk-crossfade-item {
|
|
19
|
+
grid-area: 1 / 1;
|
|
20
|
+
width: 100%;
|
|
21
|
+
min-width: 0;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Dissolve curves: BOTH sides run --duration-normal so the exchange is a
|
|
26
|
+
* true cross-fade (not a fade-out-then-in), and both ease OUT (fast
|
|
27
|
+
* start) — midway through the swap the incoming layer already carries most
|
|
28
|
+
* of the opacity while the outgoing one has already shed most of its, so
|
|
29
|
+
* the composite never dips toward the background (the classic
|
|
30
|
+
* double-exposure artifact of symmetric linear fades). The leaving item
|
|
31
|
+
* also drops pointer events: it is dead content and must not intercept
|
|
32
|
+
* clicks while it dissolves under the incoming one. */
|
|
33
|
+
.hk-crossfade-enter-active {
|
|
34
|
+
transition:
|
|
35
|
+
opacity
|
|
36
|
+
var(--duration-normal, 0.3s)
|
|
37
|
+
var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.hk-crossfade-leave-active {
|
|
41
|
+
transition: opacity var(--duration-normal, 0.3s) ease-out;
|
|
42
|
+
pointer-events: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-crossfade-enter-from,
|
|
46
|
+
.hk-crossfade-leave-to {
|
|
47
|
+
opacity: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Reduced motion: keep the opacity exchange (a fade carries no movement —
|
|
51
|
+
* HkPhaseTransition precedent), just shorter. Hosts driving the global
|
|
52
|
+
* animation switch (`html[data-css-animations="0"]`) already pin every
|
|
53
|
+
* transition via theme.scss's global rule, so no extra variant is needed
|
|
54
|
+
* here (that variant exists for ANIMATIONS, which the global rule pauses
|
|
55
|
+
* per-play-state; transitions die through its transition-duration clamp). */
|
|
56
|
+
@media (prefers-reduced-motion: reduce) {
|
|
57
|
+
.hk-crossfade-enter-active,
|
|
58
|
+
.hk-crossfade-leave-active {
|
|
59
|
+
transition: opacity var(--duration-fast, 0.15s) ease;
|
|
60
|
+
}
|
|
61
|
+
}
|