@celestia-island/hikari 0.55.83 → 0.55.85
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/HkThemeToggle.contract.test.ts +23 -0
- package/src/components/HkThemeToggle.scss +30 -0
- package/src/components/HkThemeToggle.test.ts +56 -0
- package/src/components/HkThemeToggle.tsx +15 -2
- package/src/components/HkThirdPartySignInCard.scss +38 -0
- package/src/components/HkThirdPartySignInCard.test.tsx +178 -0
- package/src/components/HkThirdPartySignInCard.tsx +130 -0
- package/src/index.ts +2 -0
package/package.json
CHANGED
|
@@ -82,4 +82,27 @@ describe("HkThemeToggle row lead-cell contract", () => {
|
|
|
82
82
|
expect(svgBlock).not.toBeNull();
|
|
83
83
|
expect(svgBlock![0]).toContain("var(--hk-menu-item-icon-box)");
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
it("pins the name-suffix mark against the name instead of the trailing edge", () => {
|
|
87
|
+
const css = read("HkThemeToggle.scss");
|
|
88
|
+
// happy-dom has no layout engine, so the "reads as part of the name"
|
|
89
|
+
// claim is pinned as the two declarations that produce it: the name
|
|
90
|
+
// stops stretching (the shared mixin's `flex: 1` would push the mark
|
|
91
|
+
// to the row's far end, next to the trailing affordances) and the
|
|
92
|
+
// suffix eats the leftover width on its leading side. Losing either
|
|
93
|
+
// one silently turns the marker into a trailing icon.
|
|
94
|
+
const nameBlock = css.match(
|
|
95
|
+
/\.s-theme-item-row\[data-name-suffix="slot"\] \.s-theme-item-name\s*\{[^}]*\}/,
|
|
96
|
+
);
|
|
97
|
+
expect(nameBlock).not.toBeNull();
|
|
98
|
+
expect(nameBlock![0]).toContain("flex: 0 1 auto");
|
|
99
|
+
// Long names must WRAP, not push the mark out of the row: the mixin's
|
|
100
|
+
// nowrap is exactly what would clip the marker away at narrow widths.
|
|
101
|
+
expect(nameBlock![0]).toContain("white-space: normal");
|
|
102
|
+
|
|
103
|
+
const suffixBlock = css.match(/\.s-theme-item-name-suffix\s*\{[^}]*\}/);
|
|
104
|
+
expect(suffixBlock).not.toBeNull();
|
|
105
|
+
expect(suffixBlock![0]).toContain("margin-inline-end: auto");
|
|
106
|
+
expect(suffixBlock![0]).toContain("flex-shrink: 0");
|
|
107
|
+
});
|
|
85
108
|
});
|
|
@@ -201,6 +201,36 @@
|
|
|
201
201
|
@include mi.label;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/* `item-name-suffix` slot — a host mark that must read as PART OF the
|
|
205
|
+
* name, not as a trailing affordance. Layout is what makes that true:
|
|
206
|
+
* the shared label mixin is `flex: 1`, which would stretch the name to
|
|
207
|
+
* the full row and leave the mark floating at the far right, next to the
|
|
208
|
+
* trailing column (and, in the host's cloud/local marker case, next to
|
|
209
|
+
* the edit button — reading as a per-row action). On suffix rows the
|
|
210
|
+
* name therefore shrinks to its content (`flex: 0 1 auto`, keeping the
|
|
211
|
+
* mixin's `min-width: 0` so long names still wrap inside the available
|
|
212
|
+
* box) and the suffix claims the leftover space on its leading side
|
|
213
|
+
* (`margin-inline-end: auto`), pinning the mark directly against the
|
|
214
|
+
* name's trailing edge. `white-space: normal` overrides the mixin's
|
|
215
|
+
* nowrap so a long name WRAPS instead of pushing the mark out of the
|
|
216
|
+
* row — a name that clips its own provenance mark defeats the point.
|
|
217
|
+
* Rows without the slot are untouched (the mixin's behaviour stands). */
|
|
218
|
+
.s-theme-item-row[data-name-suffix="slot"] .s-theme-item-name {
|
|
219
|
+
flex: 0 1 auto;
|
|
220
|
+
white-space: normal;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.s-theme-item-name-suffix {
|
|
224
|
+
display: inline-flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
flex-shrink: 0;
|
|
227
|
+
margin-inline-start: var(--space-4, 0.25rem);
|
|
228
|
+
/* Trailing auto-margin eats the row's leftover width, so the mark sits
|
|
229
|
+
* with the name at the leading edge and the trailing column stays
|
|
230
|
+
* flush right. */
|
|
231
|
+
margin-inline-end: auto;
|
|
232
|
+
}
|
|
233
|
+
|
|
204
234
|
/* Leading slot glyphs share the spec's fixed icon cell — sized for the
|
|
205
235
|
* WIDEST lead mark hosts render there, not the generic 16px box: chest's
|
|
206
236
|
* three-dot palette swatch is 28px (3×8px dots + 2px gaps), and a mark
|
|
@@ -269,6 +269,7 @@ describe("HkThemeToggle item slots", () => {
|
|
|
269
269
|
function mountSlotted(
|
|
270
270
|
lead: (scope: { id: string }) => unknown,
|
|
271
271
|
trail: (scope: { id: string }) => unknown,
|
|
272
|
+
nameSuffix?: (scope: { id: string }) => unknown,
|
|
272
273
|
): HTMLElement {
|
|
273
274
|
const container = document.createElement("div");
|
|
274
275
|
document.body.appendChild(container);
|
|
@@ -279,6 +280,9 @@ describe("HkThemeToggle item slots", () => {
|
|
|
279
280
|
}, {
|
|
280
281
|
"item-leading": lead,
|
|
281
282
|
"item-trailing": trail,
|
|
283
|
+
// Only provided when the case exercises it: the slot's ABSENCE is
|
|
284
|
+
// itself a contract (rows must stay exactly as before).
|
|
285
|
+
...(nameSuffix ? { "item-name-suffix": nameSuffix } : {}),
|
|
282
286
|
}),
|
|
283
287
|
});
|
|
284
288
|
app.mount(container);
|
|
@@ -342,6 +346,58 @@ describe("HkThemeToggle item slots", () => {
|
|
|
342
346
|
expect(customRows).toBe(2);
|
|
343
347
|
});
|
|
344
348
|
|
|
349
|
+
it("renders the name-suffix cell INSIDE the row button, right after the name", async () => {
|
|
350
|
+
// The whole point of the slot (vs. reusing item-trailing) is that the
|
|
351
|
+
// mark reads as part of the NAME. That is a DOM-position claim, so it
|
|
352
|
+
// is asserted structurally: inside `.s-theme-item-btn`, in the same
|
|
353
|
+
// row, and immediately after `.s-theme-item-name` — not merely
|
|
354
|
+
// "somewhere in the row".
|
|
355
|
+
useTheme().addCustomTheme(anyPresetTokens());
|
|
356
|
+
const container = mountSlotted(
|
|
357
|
+
() => null,
|
|
358
|
+
() => null,
|
|
359
|
+
(scope) => h("i", { class: "suffix-mark", "data-id": scope.id }),
|
|
360
|
+
);
|
|
361
|
+
await settle();
|
|
362
|
+
openMenu(container);
|
|
363
|
+
await settle();
|
|
364
|
+
|
|
365
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
366
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
367
|
+
for (const row of rows) {
|
|
368
|
+
expect(row.getAttribute("data-name-suffix")).toBe("slot");
|
|
369
|
+
const cell = row.querySelector<HTMLElement>(".s-theme-item-name-suffix .suffix-mark");
|
|
370
|
+
expect(cell).toBeTruthy();
|
|
371
|
+
expect(row.querySelectorAll(".suffix-mark")).toHaveLength(1);
|
|
372
|
+
const name = row.querySelector<HTMLElement>(".s-theme-item-name");
|
|
373
|
+
expect(name).toBeTruthy();
|
|
374
|
+
// Inside the row BUTTON (so a click on the row still selects the
|
|
375
|
+
// theme) and the name's immediate next sibling.
|
|
376
|
+
expect(name!.closest(".s-theme-item-btn")).toBeTruthy();
|
|
377
|
+
expect(cell!.closest(".s-theme-item-name-suffix")!.parentElement).toBe(name!.parentElement);
|
|
378
|
+
expect(name!.nextElementSibling).toBe(cell!.closest(".s-theme-item-name-suffix"));
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("leaves suffix rows untouched when the host provides no name-suffix slot", async () => {
|
|
383
|
+
// Absence is a contract: without the slot the row must keep the
|
|
384
|
+
// unmodified DOM (no empty cell, no data attribute), or every
|
|
385
|
+
// existing host gains a phantom cell.
|
|
386
|
+
useTheme().addCustomTheme(anyPresetTokens());
|
|
387
|
+
const container = mountSlotted(() => null, () => null);
|
|
388
|
+
await settle();
|
|
389
|
+
openMenu(container);
|
|
390
|
+
await settle();
|
|
391
|
+
|
|
392
|
+
const rows = [...document.body.querySelectorAll<HTMLElement>(".s-theme-menu .s-theme-item-row")];
|
|
393
|
+
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
expect(row.hasAttribute("data-name-suffix")).toBe(false);
|
|
396
|
+
expect(row.querySelector(".s-theme-item-name-suffix")).toBeNull();
|
|
397
|
+
expect(row.querySelector(".s-theme-item-name")).toBeTruthy();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
345
401
|
it("scopes each row with its resolved preset definition", async () => {
|
|
346
402
|
const presets: Array<unknown> = [];
|
|
347
403
|
// Two rows are the point: with a single built-in row the >= 2 bound
|
|
@@ -45,11 +45,16 @@ export interface ThemeItemScope {
|
|
|
45
45
|
* color-mode group and preset/custom theme selection (custom themes are
|
|
46
46
|
* removable), and opens HColorSchemeDialog to create a new custom scheme.
|
|
47
47
|
*
|
|
48
|
-
* Theme rows are host-customizable through
|
|
48
|
+
* Theme rows are host-customizable through three scoped slots (all render
|
|
49
49
|
* in the popover AND the mobile bottom sheet — same DOM):
|
|
50
50
|
* · `item-leading` — replaces the selected-check cell; renders for
|
|
51
51
|
* every row in the fixed icon cell so names align regardless of the
|
|
52
52
|
* active row. Scope: `ThemeItemScope` (id/name/isCustom/preset).
|
|
53
|
+
* · `item-name-suffix` — an inline cell rendered INSIDE the row button,
|
|
54
|
+
* immediately after the (catalog-resolved) name, so a host mark reads
|
|
55
|
+
* as part of the name rather than as an affordance. Renders only when
|
|
56
|
+
* the host provides the slot; the cell is `aria-hidden`-free and
|
|
57
|
+
* layout-neutral (does not grow, never pushes the trailing column).
|
|
53
58
|
* · `item-trailing` — reserves a trailing column on every row and owns
|
|
54
59
|
* it entirely; the built-in custom-delete overlay is suppressed, so
|
|
55
60
|
* the host renders delete/edit itself where it wants them.
|
|
@@ -301,7 +306,10 @@ export const HkThemeToggle = defineComponent({
|
|
|
301
306
|
{allThemeList.value.map((th) => {
|
|
302
307
|
// Host-customizable row anatomy. `item-leading` replaces the
|
|
303
308
|
// selected-check cell and renders for EVERY row (fixed icon
|
|
304
|
-
// cell, so names stay aligned); `item-
|
|
309
|
+
// cell, so names stay aligned); `item-name-suffix` renders
|
|
310
|
+
// INSIDE the row button right after the name, so a host mark
|
|
311
|
+
// (e.g. a provenance/sync glyph) reads as part of the name
|
|
312
|
+
// instead of as an affordance; `item-trailing` reserves a
|
|
305
313
|
// real trailing column for every row and takes full
|
|
306
314
|
// ownership of the affordances there — the built-in custom
|
|
307
315
|
// delete overlay is suppressed in that mode, so the host
|
|
@@ -315,6 +323,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
315
323
|
preset: presetOf(th.id),
|
|
316
324
|
};
|
|
317
325
|
const leadingSlot = slots["item-leading"];
|
|
326
|
+
const nameSuffixSlot = slots["item-name-suffix"];
|
|
318
327
|
const trailingSlot = slots["item-trailing"];
|
|
319
328
|
// A preset/custom name may be a message key into hikari's own
|
|
320
329
|
// catalog (the documented ThemePreset.name convention — hosts
|
|
@@ -329,6 +338,7 @@ export const HkThemeToggle = defineComponent({
|
|
|
329
338
|
class="s-theme-item-row"
|
|
330
339
|
data-custom={th.isCustom || undefined}
|
|
331
340
|
data-trailing={trailingSlot ? "slot" : undefined}
|
|
341
|
+
data-name-suffix={nameSuffixSlot ? "slot" : undefined}
|
|
332
342
|
>
|
|
333
343
|
<button
|
|
334
344
|
type="button"
|
|
@@ -344,6 +354,9 @@ export const HkThemeToggle = defineComponent({
|
|
|
344
354
|
<span class="hk-menu-item-icon s-theme-item-check"><Check size={14} /></span>
|
|
345
355
|
) : null}
|
|
346
356
|
<span class="s-theme-item-name">{displayName}</span>
|
|
357
|
+
{nameSuffixSlot ? (
|
|
358
|
+
<span class="s-theme-item-name-suffix">{nameSuffixSlot(scope)}</span>
|
|
359
|
+
) : null}
|
|
347
360
|
</button>
|
|
348
361
|
{trailingSlot ? (
|
|
349
362
|
<span class="s-theme-item-trailing">{trailingSlot(scope)}</span>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/* HkThirdPartySignInCard — the passwordless login card body: the
|
|
2
|
+
* third-party method list as the card's ONLY content (no credentials
|
|
3
|
+
* form, no submit button). Everything about the divider, the tile row
|
|
4
|
+
* and the tiles themselves is owned by HkAuthMethodList.scss and
|
|
5
|
+
* admin-tokens.scss; this file only re-positions the reused methods
|
|
6
|
+
* container inside the card body and promotes the tiles one size step.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* The body wrapper reuses the `.s-auth-methods` container class — it owns
|
|
10
|
+
the block's flex-column rhythm, and through the `.s-auth-methods`
|
|
11
|
+
scope the tile row's centered layout and the tooltip wrappers' flex
|
|
12
|
+
sizing resolve too. But here the block sits INSIDE the card's
|
|
13
|
+
`.s-auth-form` body instead of under a credentials form, so the
|
|
14
|
+
banding that position needs (24px top margin + 8px/2rem padding, put
|
|
15
|
+
there to separate the divider from a submit button above) collapses
|
|
16
|
+
away: the body container already owns the padding. The (0,2,0)
|
|
17
|
+
selector deterministically beats both the container's own (0,1,0)
|
|
18
|
+
rules (including the ≤480px re-declaration) and the body's generic
|
|
19
|
+
`.s-auth-form > *` child gap, whatever the stylesheet order is. */
|
|
20
|
+
.s-auth-form .s-auth-third-party-body {
|
|
21
|
+
margin-top: 0;
|
|
22
|
+
padding: 0;
|
|
23
|
+
gap: var(--space-8, 0.5rem);
|
|
24
|
+
/* Center the block vertically should a consumer give the body more
|
|
25
|
+
height than the content needs (the card itself still hugs its
|
|
26
|
+
content); horizontal centering is the tile row's own behavior. */
|
|
27
|
+
justify-content: center;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Primary-flow presence: with the tiles as the card's ONLY content, the
|
|
31
|
+
standard 44px (2.75rem) alternative-row tile reads undersized next to
|
|
32
|
+
the card header. One context-scoped step up to 52px — the frame, hover
|
|
33
|
+
wash, focus ring and icon fit all come from the unchanged tile rules
|
|
34
|
+
(the icon box is max-100% and follows the frame). */
|
|
35
|
+
.s-auth-third-party-body .s-auth-methods-tile {
|
|
36
|
+
width: 3.25rem;
|
|
37
|
+
height: 3.25rem;
|
|
38
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { createApp, h, nextTick } from "vue";
|
|
7
|
+
|
|
8
|
+
import { HkThirdPartySignInCard } from "./HkThirdPartySignInCard";
|
|
9
|
+
|
|
10
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
11
|
+
|
|
12
|
+
function mount(node: ReturnType<typeof h>) {
|
|
13
|
+
const container = document.createElement("div");
|
|
14
|
+
document.body.appendChild(container);
|
|
15
|
+
const app = createApp({ render: () => node });
|
|
16
|
+
app.mount(container);
|
|
17
|
+
mounts.push({ app, container });
|
|
18
|
+
return container;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
23
|
+
app.unmount();
|
|
24
|
+
container.remove();
|
|
25
|
+
}
|
|
26
|
+
for (const popup of [...document.querySelectorAll(".hk-tooltip-popup")]) popup.remove();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const methods = [
|
|
30
|
+
{ key: "feishu", label: "Feishu" },
|
|
31
|
+
{ key: "github", label: "GitHub" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
describe("HkThirdPartySignInCard", () => {
|
|
35
|
+
it("renders the auth-card shell with the tiles as the only body content", () => {
|
|
36
|
+
const c = mount(h(HkThirdPartySignInCard, { title: "Sign in", subtitle: "Welcome back", methods }));
|
|
37
|
+
expect(c.querySelector(".s-auth-card")).toBeTruthy();
|
|
38
|
+
expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
|
|
39
|
+
expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
|
|
40
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
|
|
41
|
+
expect(tiles.length).toBe(2);
|
|
42
|
+
// Primary and ONLY content: no credentials form, no submit button.
|
|
43
|
+
expect(c.querySelector("form")).toBeNull();
|
|
44
|
+
expect(c.querySelector("button[type='submit']")).toBeNull();
|
|
45
|
+
expect(c.querySelector(".hk-btn")).toBeNull();
|
|
46
|
+
// The methods block renders as the card BODY (inside the form-body
|
|
47
|
+
// container), not as the under-a-form `methods` slot block.
|
|
48
|
+
const blocks = c.querySelectorAll(".s-auth-methods");
|
|
49
|
+
expect(blocks.length).toBe(1);
|
|
50
|
+
expect(blocks[0]!.closest(".s-auth-form")).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("forwards the divider text to the method list", () => {
|
|
54
|
+
const c = mount(
|
|
55
|
+
h(HkThirdPartySignInCard, { title: "T", divider: "选择登录方式", methods }),
|
|
56
|
+
);
|
|
57
|
+
expect(c.querySelector(".s-auth-methods-divider")?.textContent).toContain("选择登录方式");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("emits select with the method key on click", async () => {
|
|
61
|
+
const picked: string[] = [];
|
|
62
|
+
const c = mount(
|
|
63
|
+
h(HkThirdPartySignInCard, {
|
|
64
|
+
title: "T",
|
|
65
|
+
methods,
|
|
66
|
+
onSelect: (key: string) => {
|
|
67
|
+
picked.push(key);
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
|
|
72
|
+
tiles[1]!.click();
|
|
73
|
+
await nextTick();
|
|
74
|
+
// Both tiles are asserted so a constant-literal emit cannot ride an
|
|
75
|
+
// accidental fixture coincidence.
|
|
76
|
+
tiles[0]!.click();
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(picked).toEqual(["github", "feishu"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("disables every tile while loading and swallows clicks", async () => {
|
|
82
|
+
const picked: string[] = [];
|
|
83
|
+
const c = mount(
|
|
84
|
+
h(HkThirdPartySignInCard, {
|
|
85
|
+
title: "T",
|
|
86
|
+
methods,
|
|
87
|
+
loading: true,
|
|
88
|
+
onSelect: (key: string) => {
|
|
89
|
+
picked.push(key);
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const tiles = c.querySelectorAll<HTMLButtonElement>(".s-auth-methods-tile");
|
|
94
|
+
expect(tiles.length).toBe(2);
|
|
95
|
+
for (const tile of tiles) {
|
|
96
|
+
expect(tile.disabled).toBe(true);
|
|
97
|
+
tile.click();
|
|
98
|
+
}
|
|
99
|
+
await nextTick();
|
|
100
|
+
expect(picked).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders no tiles for an empty methods list without losing the shell", () => {
|
|
104
|
+
const c = mount(h(HkThirdPartySignInCard, { title: "T", methods: [] }));
|
|
105
|
+
expect(c.querySelector(".s-auth-card")).toBeTruthy();
|
|
106
|
+
expect(c.querySelectorAll(".s-auth-methods-tile").length).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders the header logo from logoSrc", () => {
|
|
110
|
+
const c = mount(h(HkThirdPartySignInCard, { title: "T", logoSrc: "/logo.webp", methods }));
|
|
111
|
+
const img = c.querySelector(".s-auth-header img") as HTMLImageElement;
|
|
112
|
+
expect(img.getAttribute("src")).toBe("/logo.webp");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("forwards the top and footer slots", () => {
|
|
116
|
+
const c = mount(
|
|
117
|
+
h(HkThirdPartySignInCard, { title: "T", methods }, {
|
|
118
|
+
top: () => h("div", { id: "top-slot" }, "tabs"),
|
|
119
|
+
footer: () => h("div", { class: "footer-probe" }, "footer"),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const top = c.querySelector("#top-slot") as HTMLElement;
|
|
123
|
+
expect(top).toBeTruthy();
|
|
124
|
+
expect(c.querySelector(".s-auth-footer .footer-probe")).toBeTruthy();
|
|
125
|
+
// The top slot sits above the tiles in the card body.
|
|
126
|
+
const tiles = c.querySelector(".s-auth-methods-tiles") as HTMLElement;
|
|
127
|
+
expect(
|
|
128
|
+
top.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
129
|
+
).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// SCSS contract (house pattern, cf. the HkAuthCard methods-block spacing
|
|
134
|
+
// contract): happy-dom does not lay out, so the body re-positioning and
|
|
135
|
+
// the primary-flow tile bump are asserted on the sheet text. Block
|
|
136
|
+
// comments are stripped first so a commented-out declaration cannot
|
|
137
|
+
// satisfy the guard.
|
|
138
|
+
describe("HkThirdPartySignInCard SCSS contract", () => {
|
|
139
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
140
|
+
const scss = readFileSync(join(here, "./HkThirdPartySignInCard.scss"), "utf-8")
|
|
141
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
142
|
+
|
|
143
|
+
it("re-positions the reused methods container as the card body", () => {
|
|
144
|
+
const rule = scss.match(/\.s-auth-form \.s-auth-third-party-body\s*{[^}]*}/)?.[0] ?? "";
|
|
145
|
+
expect(rule, "body re-positioning rule must exist").toBeTruthy();
|
|
146
|
+
// The under-a-form banding collapses (the body container owns the
|
|
147
|
+
// padding) and the block centers when the body is taller.
|
|
148
|
+
expect(rule).toContain("margin-top: 0");
|
|
149
|
+
expect(rule).toContain("padding: 0");
|
|
150
|
+
expect(rule).toContain("justify-content: center");
|
|
151
|
+
expect(rule).toContain("gap: var(--space-8, 0.5rem)");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("promotes the tiles one size step in the primary-flow context", () => {
|
|
155
|
+
const rule = scss.match(/\.s-auth-third-party-body \.s-auth-methods-tile\s*{[^}]*}/)?.[0] ?? "";
|
|
156
|
+
expect(rule, "tile bump rule must exist").toBeTruthy();
|
|
157
|
+
expect(rule).toContain("width: 3.25rem");
|
|
158
|
+
expect(rule).toContain("height: 3.25rem");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("HkThirdPartySignInCard public export surface", () => {
|
|
163
|
+
// 2026-09-28 R1 finding: removing either spelling from index.ts passed
|
|
164
|
+
// every gate — nothing guarded the barrel. Importing the full barrel in a
|
|
165
|
+
// test hangs (750+ transitive modules), so the guard is source-pinned:
|
|
166
|
+
// both export lines must exist verbatim. A dropped line goes red here.
|
|
167
|
+
it("exports both the canonical Hk* and legacy H* spellings from the barrel", async () => {
|
|
168
|
+
const { readFile } = await import("node:fs/promises");
|
|
169
|
+
const { resolve } = await import("node:path");
|
|
170
|
+
const index = await readFile(resolve(__dirname, "../index.ts"), "utf8");
|
|
171
|
+
expect(index).toContain(
|
|
172
|
+
'export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";',
|
|
173
|
+
);
|
|
174
|
+
expect(index).toContain(
|
|
175
|
+
'export { HkThirdPartySignInCard as HThirdPartySignInCard } from "./components/HkThirdPartySignInCard";',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
import { HkAuthCard } from "./HkAuthCard";
|
|
4
|
+
import HkAuthMethodList from "./HkAuthMethodList";
|
|
5
|
+
|
|
6
|
+
import "./HkThirdPartySignInCard.scss";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HkThirdPartySignInCard — the passwordless sign-in card: the auth shell
|
|
10
|
+
* with third-party method tiles as its PRIMARY and ONLY content.
|
|
11
|
+
*
|
|
12
|
+
* For deployments where password login is disabled (the ERP login page in
|
|
13
|
+
* Feishu-only mode, shittim-chest's org channel), the login UI must show
|
|
14
|
+
* centered provider buttons and NOTHING else — no username/password
|
|
15
|
+
* fields, no submit button, no "alternative ways" framing. This card is
|
|
16
|
+
* that composition: `HkAuthCard` (title/subtitle/logo header) with an
|
|
17
|
+
* `HkAuthMethodList` rendered into the card's form-body slot, promoted to
|
|
18
|
+
* primary-flow presence through a context-scoped tile modifier.
|
|
19
|
+
*
|
|
20
|
+
* NOTE on `methods`: it is a PROP (the provider array). Unlike HkSignInCard
|
|
21
|
+
* there is NO `#methods` slot on this card — a slot of that name is silently
|
|
22
|
+
* ignored; pass the array through the prop.
|
|
23
|
+
*
|
|
24
|
+
* Control contract (same split as HkSignInCard): the card owns no state
|
|
25
|
+
* and never talks to a backend. The consumer passes the provider
|
|
26
|
+
* `methods` (same item shape as HkAuthMethodList) and binds the OAuth
|
|
27
|
+
* redirect/popup to `select(key)`; the in-flight state feeds back through
|
|
28
|
+
* `loading`, which disables every tile for the duration.
|
|
29
|
+
*
|
|
30
|
+
* Layout contract: the list renders into the card BODY (`.s-auth-form`
|
|
31
|
+
* container), NOT the `methods` slot — that slot's top margin and banding
|
|
32
|
+
* exist to separate "alternative" buttons from a credentials form above,
|
|
33
|
+
* and would read as dead space with nothing above them. The body wrapper
|
|
34
|
+
* (`.s-auth-third-party-body`) restores the methods block's own
|
|
35
|
+
* divider→tile rhythm and centers the row; its scss also scales the tiles
|
|
36
|
+
* up one step, because the standard 44px "alternative row" tile reads
|
|
37
|
+
* undersized when it is the whole card.
|
|
38
|
+
*
|
|
39
|
+
* Slots: `top` (content between the header and the tiles — channel tabs
|
|
40
|
+
* and similar; there is no `<form>` to keep it out of), `footer`
|
|
41
|
+
* (remember-me / protocol rows, forwarded to HkAuthCard's centered
|
|
42
|
+
* footer group) and `logo` (replaces the `logoSrc` image).
|
|
43
|
+
*
|
|
44
|
+
* i18n: hikari ships no dictionary for consumer copy — `title`,
|
|
45
|
+
* `subtitle` and the `divider` text are all consumer-localized on
|
|
46
|
+
* purpose, exactly like HkAuthMethodList's divider.
|
|
47
|
+
*
|
|
48
|
+
* ```tsx
|
|
49
|
+
* <HThirdPartySignInCard
|
|
50
|
+
* title="Sign in"
|
|
51
|
+
* subtitle="Continue with Feishu"
|
|
52
|
+
* :logo-src="logo"
|
|
53
|
+
* :loading="pending"
|
|
54
|
+
* :methods="[{ key: 'feishu', label: 'Feishu', icon: feishuIcon }]"
|
|
55
|
+
* @select="(key) => startOAuth(key)"
|
|
56
|
+
* />
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export const HkThirdPartySignInCard = defineComponent({
|
|
60
|
+
name: "HkThirdPartySignInCard",
|
|
61
|
+
props: {
|
|
62
|
+
title: { type: String, required: true },
|
|
63
|
+
subtitle: { type: String, default: "" },
|
|
64
|
+
/** Optional logo image URL for the card header. */
|
|
65
|
+
logoSrc: { type: String, default: undefined },
|
|
66
|
+
/** External in-flight state; disables every tile while true. */
|
|
67
|
+
loading: { type: Boolean, default: false },
|
|
68
|
+
/** Optional divider text above the tiles, forwarded to
|
|
69
|
+
* HkAuthMethodList (consumer-localized on purpose). */
|
|
70
|
+
divider: { type: String, default: "" },
|
|
71
|
+
/** Provider entries, in render order — same item shape as
|
|
72
|
+
* HkAuthMethodList's `methods`. */
|
|
73
|
+
methods: {
|
|
74
|
+
type: Array as unknown as () => Array<{
|
|
75
|
+
key: string;
|
|
76
|
+
/** Accessible name + tooltip text (the hover reveal). */
|
|
77
|
+
label: string;
|
|
78
|
+
/** Prebuilt icon vnode rendered inside the framed tile; a
|
|
79
|
+
* missing icon falls back to the label's initial. */
|
|
80
|
+
icon?: unknown;
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
}>,
|
|
83
|
+
required: true,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
emits: {
|
|
87
|
+
/** A provider tile was clicked; carries the method `key`. */
|
|
88
|
+
select: (_key: string) => true,
|
|
89
|
+
},
|
|
90
|
+
setup(props, { emit, slots }) {
|
|
91
|
+
return () => (
|
|
92
|
+
<HkAuthCard title={props.title} subtitle={props.subtitle}>
|
|
93
|
+
{{
|
|
94
|
+
logo: () =>
|
|
95
|
+
slots.logo ? (
|
|
96
|
+
slots.logo()
|
|
97
|
+
) : props.logoSrc ? (
|
|
98
|
+
<img class="hk-logo-img" src={props.logoSrc} alt="" style={{ width: "3.5rem", height: "3.5rem" }} />
|
|
99
|
+
) : null,
|
|
100
|
+
default: () => (
|
|
101
|
+
<>
|
|
102
|
+
{slots.top?.()}
|
|
103
|
+
{/* The wrapper carries BOTH classes: `s-auth-methods` is
|
|
104
|
+
the container HkAuthMethodList's scoped rules resolve
|
|
105
|
+
against (tile-row centering, tooltip-wrapper sizing),
|
|
106
|
+
`s-auth-third-party-body` re-positions it as the card
|
|
107
|
+
body — see the scss. */}
|
|
108
|
+
<div class="s-auth-methods s-auth-third-party-body">
|
|
109
|
+
{/* `loading` maps onto the per-entry disabled flag so the
|
|
110
|
+
list itself stays untouched: HkAuthMethodList already
|
|
111
|
+
renders disabled tiles dead and click-swallowing. An
|
|
112
|
+
entry's own disabled bit survives (OR, not replace). */}
|
|
113
|
+
<HkAuthMethodList
|
|
114
|
+
divider={props.divider}
|
|
115
|
+
methods={
|
|
116
|
+
props.loading
|
|
117
|
+
? props.methods.map((method) => ({ ...method, disabled: true }))
|
|
118
|
+
: props.methods
|
|
119
|
+
}
|
|
120
|
+
onSelect={(key: string) => emit("select", key)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</>
|
|
124
|
+
),
|
|
125
|
+
footer: () => slots.footer?.(),
|
|
126
|
+
}}
|
|
127
|
+
</HkAuthCard>
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -472,6 +472,7 @@ export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components
|
|
|
472
472
|
export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
|
|
473
473
|
export { default as HAuthMethodList } from "./components/HkAuthMethodList";
|
|
474
474
|
export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
|
|
475
|
+
export { HkThirdPartySignInCard as HThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
|
|
475
476
|
export { default as HAuthSubmitButton } from "./components/HkAuthSubmitButton";
|
|
476
477
|
export {
|
|
477
478
|
HkMfaVerifyCard as HMfaVerifyCard,
|
|
@@ -745,6 +746,7 @@ export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
|
|
|
745
746
|
export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
|
|
746
747
|
export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
|
|
747
748
|
export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
|
|
749
|
+
export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";
|
|
748
750
|
export { HkMfaVerifyCard as HkMfaVerifyCard } from "./components/HkMfaVerifyCard";
|
|
749
751
|
export { type HkMfaFactor as HkMfaFactor } from "./components/HkMfaVerifyCard";
|
|
750
752
|
export { HkLocalePicker as HkLocalePicker } from "./components/HkLocalePicker";
|