@celestia-island/hikari 0.45.9 → 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/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 +5 -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,104 @@
|
|
|
1
|
+
/* HkIconButtonGroup — the icon-only button-group variant.
|
|
2
|
+
*
|
|
3
|
+
* Same visual language as the HkTabs strip (the ONE shared button-group
|
|
4
|
+
* look): inset track, muted items, primary accent on the active item,
|
|
5
|
+
* tactile press scale. Only the working-mode extras differ — the items
|
|
6
|
+
* are square, icon-only, and one size taller than a standard icon
|
|
7
|
+
* button (md 44px vs 32px), the Windows/macOS-style login chooser.
|
|
8
|
+
*
|
|
9
|
+
* Sizing is a component var so hosts can retune one axis per context:
|
|
10
|
+
* --hk-icon-group-item-size
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
.hk-icon-group {
|
|
14
|
+
--hk-icon-group-item-size: 2.75rem; /* md */
|
|
15
|
+
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
flex-wrap: wrap;
|
|
20
|
+
gap: var(--space-2, 0.125rem);
|
|
21
|
+
max-width: 100%;
|
|
22
|
+
padding: var(--space-2, 0.125rem);
|
|
23
|
+
border: 1px solid var(--border-faint);
|
|
24
|
+
border-radius: var(--radius-md, 8px);
|
|
25
|
+
background: rgb(var(--color-surface, 240 244 248) / 60%);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hk-icon-group-sm {
|
|
29
|
+
--hk-icon-group-item-size: 2.25rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.hk-icon-group-lg {
|
|
33
|
+
--hk-icon-group-item-size: 3.25rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The HkTooltip wrapper span is the flex child between items — let it
|
|
37
|
+
shrink to the button so the track gap (not the wrapper) spaces them. */
|
|
38
|
+
.hk-icon-group > .hk-tooltip-wrapper {
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
flex: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.hk-icon-group-item {
|
|
44
|
+
display: inline-flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
width: var(--hk-icon-group-item-size);
|
|
48
|
+
height: var(--hk-icon-group-item-size);
|
|
49
|
+
padding: 0;
|
|
50
|
+
font: inherit;
|
|
51
|
+
color: rgb(var(--color-muted, 120 130 145));
|
|
52
|
+
background: transparent;
|
|
53
|
+
border: none;
|
|
54
|
+
border-radius: var(--radius-sm, 4px);
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
outline: none;
|
|
57
|
+
transition: color var(--duration-short, 0.15s) ease,
|
|
58
|
+
background-color var(--duration-short, 0.15s) ease,
|
|
59
|
+
transform var(--duration-short, 0.15s) ease;
|
|
60
|
+
|
|
61
|
+
/* A real <button disabled> still matches :hover — guard the dead ones. */
|
|
62
|
+
&:hover:not(:disabled):not([data-active]) {
|
|
63
|
+
color: rgb(var(--color-text, 25 30 40));
|
|
64
|
+
background: rgb(var(--color-text, 25 30 40) / 6%);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Tactile press feedback — the button-group family cadence. */
|
|
68
|
+
&:active:not(:disabled) {
|
|
69
|
+
transform: scale(0.96);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&[data-active] {
|
|
73
|
+
color: rgb(var(--color-primary, 90 140 255));
|
|
74
|
+
background: rgb(var(--color-primary, 90 140 255) / 12%);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Inset ring: the track padding would clip an outset outline. */
|
|
78
|
+
&:focus-visible {
|
|
79
|
+
outline: 2px solid rgb(var(--color-primary, 90 140 255));
|
|
80
|
+
outline-offset: -2px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
&:disabled {
|
|
84
|
+
opacity: 0.5;
|
|
85
|
+
cursor: not-allowed;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.hk-icon-group-item-icon {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
justify-content: center;
|
|
93
|
+
line-height: 0;
|
|
94
|
+
/* Brand marks (svg/img vnodes) at their intrinsic size; lucide comps
|
|
95
|
+
sized by the host. The box never grows past the square. */
|
|
96
|
+
max-width: 100%;
|
|
97
|
+
max-height: 100%;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.hk-icon-group-item-initial {
|
|
101
|
+
font-size: var(--text-sm, 0.8125rem);
|
|
102
|
+
font-weight: 600;
|
|
103
|
+
line-height: 1;
|
|
104
|
+
}
|