@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.45.8",
3
+ "version": "0.46.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",
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
- /* Full-width stacked alternative sign-in buttons (login card). The block
2
- container `.s-auth-methods` and its spacing are owned by HkAuthCard's
3
- slot styles (styles/admin-tokens.scss); this file only styles the
4
- divider and the buttons the component renders into that slot. */
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` (measurement anchor +
26
- CSS-var inheritance) — layout-wise the buttons remain direct children of
27
- HkAuthCard's `.s-auth-methods` slot container. */
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
- /* Fixed icon + label columns: the two spans keep the same width on every
33
- row, so icons and labels align across buttons while each button stays a
34
- centered hk-btn-block. The part classes sit on the default-slot spans
35
- inside HkButton's single <button> root — scope everything off the
36
- `.s-auth-methods` container (descendant match: the component wrapper
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 one fixed-column button per method and an optional divider", () => {
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 buttons are layout children of it
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 buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
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
- const labels = [...buttons].map(
42
- (b) => b.querySelector<HTMLElement>(".s-auth-methods-label")?.textContent,
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
- expect(labels).toEqual(["GitHub", "LinuxDo"]);
45
- for (const b of buttons) {
46
- expect(b.querySelector(".s-auth-methods-icon")).toBeTruthy();
47
- expect(b.classList.contains("hk-btn-block")).toBe(true);
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
- let picked = "";
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 = key;
70
+ picked.push(key);
58
71
  },
59
72
  })),
60
73
  );
61
- const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-btn");
74
+ const buttons = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods .hk-icon-group-item");
62
75
  buttons[1]!.click();
63
76
  await nextTick();
64
- expect(picked).toBe("linuxdo");
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
- const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
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("leaves the column fallback intact when no text metrics are available", async () => {
122
- const c = mount(h("div", { class: "s-auth-methods" }, h(HkAuthMethodList, { methods })));
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
- const wrapper = c.querySelector<HTMLElement>(".s-auth-methods-list")!;
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, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
- import HkButton from "./HkButton";
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 — full-width stacked third-party sign-in buttons for
8
- * auth cards (the ERP login's provider-button grammar, componentized).
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
- * Every button renders [icon | label] with a FIXED icon column and a FIXED
11
- * label column, so the icons and the label text start at the same x on
12
- * every row regardless of label length; because every content block is
13
- * then the same width, the row can stay centered like any HkButton
14
- * without the columns drifting.
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 label column is auto-sized: after mount (and whenever the label set,
17
- * the document fonts, the browser zoom, or the container size change) the
18
- * component measures the widest label text and publishes it as
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 buttons, e.g. "其他方式登录".
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>, …) — `null` renders an
44
- * empty fixed-width column so the labels still align. Typed loose
45
- * on purpose (same as HkAltSignIn entries): hosts materialize
46
- * hikari against their own vue store, and a hard VNode type
47
- * breaks typecheck whenever the host's vue minor differs. The
48
- * value renders as-is inside the fixed-size column. */
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 method button was clicked. */
50
+ /** A provider button was clicked. */
57
51
  select: (_key: string) => true,
58
52
  },
59
- setup(props, { emit, expose }) {
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" ref={listRef}>
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
- {props.methods.map((method) => (
170
- <HkButton
171
- key={method.key}
172
- variant="outline"
173
- size="md"
174
- block
175
- disabled={method.disabled === true}
176
- onClick={() => emit("select", method.key)}
177
- >
178
- <span class="s-auth-methods-icon">{method.icon}</span>
179
- <span class="s-auth-methods-label">{method.label}</span>
180
- </HkButton>
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
+ }