@celestia-island/hikari 0.55.27 → 0.55.29
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/HkDockBar.scss +49 -2
- package/src/components/HkDockBar.test.tsx +135 -8
- package/src/components/HkDockBar.tsx +32 -7
- package/src/components/HkIconButtonGroup.scss +27 -0
- package/src/components/HkIconButtonGroup.test.tsx +39 -1
- package/src/components/HkIconButtonGroup.tsx +12 -0
- package/src/components/HkSignInCard.test.ts +53 -0
- package/src/components/HkSignInCard.tsx +37 -15
package/package.json
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
align-items: center;
|
|
15
15
|
gap: var(--space-4, 4px);
|
|
16
16
|
|
|
17
|
-
/* ── page anchor: absolute bottom-center of the nearest
|
|
18
|
-
* ancestor, resting --hk-dock-inset above its content
|
|
17
|
+
/* ── page anchor (SOUTH edge): absolute bottom-center of the nearest
|
|
18
|
+
* positioned ancestor, resting --hk-dock-inset above its content
|
|
19
|
+
* floor. `bottom` below is the compass spelling of the same
|
|
20
|
+
* geometry. */
|
|
19
21
|
&[data-anchor="page"] {
|
|
20
22
|
position: absolute;
|
|
21
23
|
left: 50%;
|
|
@@ -31,6 +33,42 @@
|
|
|
31
33
|
margin: 0 auto var(--hk-dock-inset, 12px);
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/* ── horizontal edge anchors: north and the compass spelling of the
|
|
37
|
+
* south edge — absolute, centered along the edge. */
|
|
38
|
+
&[data-anchor="top"],
|
|
39
|
+
&[data-anchor="bottom"] {
|
|
40
|
+
position: absolute;
|
|
41
|
+
left: 50%;
|
|
42
|
+
transform: translateX(-50%);
|
|
43
|
+
z-index: 50;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
&[data-anchor="top"] {
|
|
47
|
+
top: var(--hk-dock-inset, 12px);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
&[data-anchor="bottom"] {
|
|
51
|
+
bottom: var(--hk-dock-inset, 12px);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* ── vertical edge anchors (west / east): the dock runs vertically,
|
|
55
|
+
* centered on the edge midpoint. */
|
|
56
|
+
&[data-anchor="left"],
|
|
57
|
+
&[data-anchor="right"] {
|
|
58
|
+
position: absolute;
|
|
59
|
+
top: 50%;
|
|
60
|
+
transform: translateY(-50%);
|
|
61
|
+
z-index: 50;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
&[data-anchor="left"] {
|
|
65
|
+
left: var(--hk-dock-inset, 12px);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&[data-anchor="right"] {
|
|
69
|
+
right: var(--hk-dock-inset, 12px);
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
/* ── corner anchors: absolute attachment with the same inset, for
|
|
35
73
|
* pagers, toolbars and HUD panels that live off-center. */
|
|
36
74
|
&[data-anchor="top-left"],
|
|
@@ -73,6 +111,7 @@
|
|
|
73
111
|
align-items: stretch;
|
|
74
112
|
width: var(--dock-width, auto);
|
|
75
113
|
max-width: var(--dock-max-width, calc(100vw - var(--space-16, 16px)));
|
|
114
|
+
max-height: var(--dock-max-height, none);
|
|
76
115
|
padding: var(--dock-padding, var(--space-8, 8px) var(--space-12, 12px));
|
|
77
116
|
background: rgb(var(--color-surface));
|
|
78
117
|
border: 1px solid var(--border-subtle);
|
|
@@ -82,6 +121,14 @@
|
|
|
82
121
|
box-shadow: var(--glow-dock);
|
|
83
122
|
}
|
|
84
123
|
|
|
124
|
+
/* Vertical side docks (west / east edges) stack along the viewport's
|
|
125
|
+
* tall axis — cap them viewport-aware by default so a tall dock never
|
|
126
|
+
* runs off-screen; --dock-max-height still overrides. */
|
|
127
|
+
.hk-dock-bar[data-anchor="left"] .hk-dock-bar-surface,
|
|
128
|
+
.hk-dock-bar[data-anchor="right"] .hk-dock-bar-surface {
|
|
129
|
+
max-height: var(--dock-max-height, calc(100dvh - var(--space-16, 16px)));
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
/* Glass surface (default) — blur the page canvas behind the dock. */
|
|
86
133
|
.hk-dock-bar[data-surface="glass"] .hk-dock-bar-surface {
|
|
87
134
|
backdrop-filter: blur(var(--blur-lg));
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
2
5
|
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
6
|
|
|
4
7
|
import HkDockBar from "./HkDockBar";
|
|
@@ -49,23 +52,34 @@ describe("HkDockBar", () => {
|
|
|
49
52
|
expect(root.dataset.surface).toBe("glass");
|
|
50
53
|
});
|
|
51
54
|
|
|
52
|
-
it.each(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
it.each(
|
|
56
|
+
[
|
|
57
|
+
"plane",
|
|
58
|
+
"top",
|
|
59
|
+
"bottom",
|
|
60
|
+
"left",
|
|
61
|
+
"right",
|
|
62
|
+
"top-left",
|
|
63
|
+
"top-right",
|
|
64
|
+
"bottom-left",
|
|
65
|
+
"bottom-right",
|
|
66
|
+
] as const,
|
|
67
|
+
)("accepts the %s anchor", async (anchor) => {
|
|
68
|
+
const root = await mountDock({ anchor });
|
|
69
|
+
expect(root.dataset.anchor).toBe(anchor);
|
|
70
|
+
});
|
|
59
71
|
|
|
60
|
-
it("exposes style hooks for width, max-width and padding", async () => {
|
|
72
|
+
it("exposes style hooks for width, max-width, max-height and padding", async () => {
|
|
61
73
|
const root = await mountDock({
|
|
62
74
|
width: "22rem",
|
|
63
75
|
maxWidth: "30rem",
|
|
76
|
+
maxHeight: "60vh",
|
|
64
77
|
padding: "4px 6px",
|
|
65
78
|
});
|
|
66
79
|
const style = root.style;
|
|
67
80
|
expect(style.getPropertyValue("--dock-width")).toBe("22rem");
|
|
68
81
|
expect(style.getPropertyValue("--dock-max-width")).toBe("30rem");
|
|
82
|
+
expect(style.getPropertyValue("--dock-max-height")).toBe("60vh");
|
|
69
83
|
expect(style.getPropertyValue("--dock-padding")).toBe("4px 6px");
|
|
70
84
|
});
|
|
71
85
|
|
|
@@ -74,3 +88,116 @@ describe("HkDockBar", () => {
|
|
|
74
88
|
expect(root.dataset.surface).toBe("solid");
|
|
75
89
|
});
|
|
76
90
|
});
|
|
91
|
+
|
|
92
|
+
// ── Anchor geometry contract ──────────────────────────────────────────────
|
|
93
|
+
// SCSS source contract (house pattern, cf. HkButton icon-only contract):
|
|
94
|
+
// happy-dom does not lay out, so each compass direction's geometry is
|
|
95
|
+
// pinned textually against HkDockBar.scss. Dropping any one direction's
|
|
96
|
+
// rule — or regressing its mapping (inset side / centering transform) —
|
|
97
|
+
// turns the matching assertion red.
|
|
98
|
+
|
|
99
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
100
|
+
const scss = readFileSync(join(here, "HkDockBar.scss"), "utf8");
|
|
101
|
+
|
|
102
|
+
/** Every SCSS rule body whose selector list mentions the anchor. */
|
|
103
|
+
function rulesFor(anchor: string): string {
|
|
104
|
+
const re = new RegExp(`&\\[data-anchor="${anchor}"\\][^{]*\\{[^}]*\\}`, "g");
|
|
105
|
+
return scss.match(re)?.join("\n") ?? "";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("HkDockBar anchor geometry contract", () => {
|
|
109
|
+
const INSET = "var(--hk-dock-inset, 12px)";
|
|
110
|
+
|
|
111
|
+
it.each(["page", "bottom"] as const)(
|
|
112
|
+
"%s docks to the south edge, centered (the two spellings share geometry)",
|
|
113
|
+
(anchor) => {
|
|
114
|
+
const rules = rulesFor(anchor);
|
|
115
|
+
expect(rules, `${anchor} rules must exist`).toBeTruthy();
|
|
116
|
+
expect(rules).toContain("position: absolute");
|
|
117
|
+
expect(rules).toContain("left: 50%");
|
|
118
|
+
expect(rules).toContain(`bottom: ${INSET}`);
|
|
119
|
+
expect(rules).toContain("transform: translateX(-50%)");
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
it("top docks to the north edge, centered", () => {
|
|
124
|
+
const rules = rulesFor("top");
|
|
125
|
+
expect(rules, "top rules must exist").toBeTruthy();
|
|
126
|
+
expect(rules).toContain("position: absolute");
|
|
127
|
+
expect(rules).toContain("top: " + INSET);
|
|
128
|
+
expect(rules).toContain("left: 50%");
|
|
129
|
+
expect(rules).toContain("transform: translateX(-50%)");
|
|
130
|
+
expect(rules).not.toContain(`bottom: ${INSET}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it.each(["left", "right"] as const)(
|
|
134
|
+
"%s docks to the vertical edge midpoint, centered by translateY",
|
|
135
|
+
(anchor) => {
|
|
136
|
+
const rules = rulesFor(anchor);
|
|
137
|
+
expect(rules, `${anchor} rules must exist`).toBeTruthy();
|
|
138
|
+
expect(rules).toContain("position: absolute");
|
|
139
|
+
expect(rules).toContain("top: 50%");
|
|
140
|
+
expect(rules).toContain(`${anchor}: ${INSET}`);
|
|
141
|
+
expect(rules).toContain("transform: translateY(-50%)");
|
|
142
|
+
// A side dock must not pick up the horizontal-edge centering.
|
|
143
|
+
expect(rules).not.toContain("transform: translateX(-50%)");
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
it.each(
|
|
148
|
+
[
|
|
149
|
+
["top-left", "top", "left", "flex-start"],
|
|
150
|
+
["top-right", "top", "right", "flex-end"],
|
|
151
|
+
["bottom-left", "bottom", "left", "flex-start"],
|
|
152
|
+
["bottom-right", "bottom", "right", "flex-end"],
|
|
153
|
+
] as const,
|
|
154
|
+
)("%s docks into its corner with both insets", (anchor, side1, side2, align) => {
|
|
155
|
+
const rules = rulesFor(anchor);
|
|
156
|
+
expect(rules, `${anchor} rules must exist`).toBeTruthy();
|
|
157
|
+
expect(rules).toContain("position: absolute");
|
|
158
|
+
expect(rules).toContain(`${side1}: ${INSET}`);
|
|
159
|
+
expect(rules).toContain(`${side2}: ${INSET}`);
|
|
160
|
+
expect(rules).toContain(`align-items: ${align}`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("page and plane stay semantically distinct: page floats, plane flows", () => {
|
|
164
|
+
const page = rulesFor("page");
|
|
165
|
+
const plane = rulesFor("plane");
|
|
166
|
+
expect(page).toContain("position: absolute");
|
|
167
|
+
expect(plane, "plane rule must exist").toBeTruthy();
|
|
168
|
+
expect(plane).toContain("margin: 0 auto");
|
|
169
|
+
expect(plane).not.toContain("position: absolute");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("consumes the size custom properties on every anchor via the base surface rule", () => {
|
|
173
|
+
// The anchor-agnostic base surface rule is the ONLY SCSS consumer of
|
|
174
|
+
// the size props for page/top/bottom/plane/corner docks — only the
|
|
175
|
+
// vertical side anchors get the viewport-aware cap override below.
|
|
176
|
+
// The style-hook tests above pin that the custom properties are set;
|
|
177
|
+
// this pins that the base rule still consumes them, so maxWidth /
|
|
178
|
+
// maxHeight cannot silently stop working off-side.
|
|
179
|
+
const base = scss.match(/\.hk-dock-bar-surface\s*\{[^}]*\}/)?.[0];
|
|
180
|
+
expect(base, "base surface rule must exist").toBeTruthy();
|
|
181
|
+
expect(base).toContain("width: var(--dock-width, auto)");
|
|
182
|
+
expect(base).toContain("max-width: var(--dock-max-width");
|
|
183
|
+
expect(base).toContain("max-height: var(--dock-max-height, none)");
|
|
184
|
+
expect(base).toContain("padding: var(--dock-padding");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("caps vertical side docks viewport-aware by default", () => {
|
|
188
|
+
const sideCap = scss.match(
|
|
189
|
+
/\.hk-dock-bar\[data-anchor="left"\] \.hk-dock-bar-surface[^{]*\{[^}]*\}/,
|
|
190
|
+
)?.[0];
|
|
191
|
+
expect(sideCap, "side-anchor surface cap rule must exist").toBeTruthy();
|
|
192
|
+
expect(sideCap).toContain("max-height: var(--dock-max-height");
|
|
193
|
+
expect(sideCap).toContain("100dvh");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("solid surface drops the backdrop blur in CSS, not just in data", () => {
|
|
197
|
+
const solid = scss.match(
|
|
198
|
+
/\.hk-dock-bar\[data-surface="solid"\] \.hk-dock-bar-surface\s*\{[^}]*\}/,
|
|
199
|
+
)?.[0];
|
|
200
|
+
expect(solid, "solid surface rule must exist").toBeTruthy();
|
|
201
|
+
expect(solid).toContain("backdrop-filter: none");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -5,6 +5,10 @@ import "./HkDockBar.scss";
|
|
|
5
5
|
export type HkDockBarAnchor =
|
|
6
6
|
| "page"
|
|
7
7
|
| "plane"
|
|
8
|
+
| "top"
|
|
9
|
+
| "bottom"
|
|
10
|
+
| "left"
|
|
11
|
+
| "right"
|
|
8
12
|
| "top-left"
|
|
9
13
|
| "top-right"
|
|
10
14
|
| "bottom-left"
|
|
@@ -19,14 +23,30 @@ export type HkDockBarSurface = "glass" | "solid";
|
|
|
19
23
|
* (surface, blur, border, radius, reverse glow) means no dock surface
|
|
20
24
|
* ever re-declares its own background or shadow again.
|
|
21
25
|
*
|
|
22
|
-
* Anchors:
|
|
23
|
-
* - `page`
|
|
24
|
-
* ancestor, `--hk-dock-inset`
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* Anchors — the full eight-way compass (4 edges + 4 corners):
|
|
27
|
+
* - `page` (default) and `bottom`: absolute bottom-center of the
|
|
28
|
+
* nearest positioned ancestor (the SOUTH edge), `--hk-dock-inset`
|
|
29
|
+
* above its content floor. `page` is the 2026-09-02 spelling kept
|
|
30
|
+
* for existing callers; `bottom` is its compass alias — identical
|
|
31
|
+
* geometry, pick either.
|
|
32
|
+
* - `top`: absolute top-center (north edge) with the same inset.
|
|
33
|
+
* - `left` / `right`: absolute middle of the west / east edge — the
|
|
34
|
+
* dock runs vertically and is centered with translateY; its surface
|
|
35
|
+
* gets a viewport-aware default max-height.
|
|
27
36
|
* - `top-left` / `top-right` / `bottom-left` / `bottom-right`:
|
|
28
37
|
* absolute corner attachment with the same inset, for pagers,
|
|
29
38
|
* toolbars and HUD panels that live off-center.
|
|
39
|
+
* - `plane`: NOT a compass direction — in-flow; the host provides
|
|
40
|
+
* the plane (e.g. a fixed footer band) and this container centers
|
|
41
|
+
* the surface horizontally. Use it when the host owns positioning.
|
|
42
|
+
*
|
|
43
|
+
* Relationship to HkScrollContainer: that component's dockTop /
|
|
44
|
+
* dockBottom named slots are the contract for docks that live INSIDE a
|
|
45
|
+
* scrolling region — pinned to the scroll viewport, excluded from the
|
|
46
|
+
* scroll flow. HkDockBar is the carrier for docks that FLOAT over a
|
|
47
|
+
* canvas or page instead. Pick by host: scrolling region →
|
|
48
|
+
* HkScrollContainer dock slots; canvas/page overlay → HkDockBar. Do
|
|
49
|
+
* not nest an HkDockBar inside a scroll viewport expecting pinning.
|
|
30
50
|
*
|
|
31
51
|
* Surfaces:
|
|
32
52
|
* - `glass` (default): translucent surface + backdrop blur.
|
|
@@ -39,8 +59,9 @@ export type HkDockBarSurface = "glass" | "solid";
|
|
|
39
59
|
const HkDockBar = defineComponent({
|
|
40
60
|
name: "HkDockBar",
|
|
41
61
|
props: {
|
|
42
|
-
/** Which plane carries the dock:
|
|
43
|
-
*
|
|
62
|
+
/** Which plane carries the dock: a compass anchor (4 edges + 4
|
|
63
|
+
* corners, `page` being the south edge), or a host plane
|
|
64
|
+
* (in-flow). */
|
|
44
65
|
anchor: { type: String as PropType<HkDockBarAnchor>, default: "page" },
|
|
45
66
|
/** Surface finish: glass (blur) or solid (moving canvas). */
|
|
46
67
|
surface: { type: String as PropType<HkDockBarSurface>, default: "glass" },
|
|
@@ -48,6 +69,9 @@ const HkDockBar = defineComponent({
|
|
|
48
69
|
width: { type: String, default: undefined },
|
|
49
70
|
/** CSS max-width of the surface (content caps, e.g. minimap clearance). */
|
|
50
71
|
maxWidth: { type: String, default: undefined },
|
|
72
|
+
/** CSS max-height of the surface; defaults to a viewport-aware cap
|
|
73
|
+
* on the vertical side anchors (left / right), none elsewhere. */
|
|
74
|
+
maxHeight: { type: String, default: undefined },
|
|
51
75
|
/** CSS padding override; defaults to the shared chrome rhythm. */
|
|
52
76
|
padding: { type: String, default: undefined },
|
|
53
77
|
},
|
|
@@ -56,6 +80,7 @@ const HkDockBar = defineComponent({
|
|
|
56
80
|
const s: Record<string, string> = {};
|
|
57
81
|
if (props.width) s["--dock-width"] = props.width;
|
|
58
82
|
if (props.maxWidth) s["--dock-max-width"] = props.maxWidth;
|
|
83
|
+
if (props.maxHeight) s["--dock-max-height"] = props.maxHeight;
|
|
59
84
|
if (props.padding) s["--dock-padding"] = props.padding;
|
|
60
85
|
return s;
|
|
61
86
|
});
|
|
@@ -107,6 +107,7 @@
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
.hk-icon-group-item {
|
|
110
|
+
position: relative;
|
|
110
111
|
display: inline-flex;
|
|
111
112
|
align-items: center;
|
|
112
113
|
justify-content: center;
|
|
@@ -168,3 +169,29 @@
|
|
|
168
169
|
font-weight: 600;
|
|
169
170
|
line-height: 1;
|
|
170
171
|
}
|
|
172
|
+
|
|
173
|
+
/* Status marker (HkIconButtonGroupOption.marker) — a small dot pinned to
|
|
174
|
+
* the item's bottom-right corner. "success" = the theme's success green
|
|
175
|
+
* with a surface-colored ring so it stays legible on top of the icon's
|
|
176
|
+
* corner and across active/muted tints. Purely decorative: the accessible
|
|
177
|
+
* meaning lives in the option's label/tooltip the host controls. */
|
|
178
|
+
.hk-icon-group-item-marker {
|
|
179
|
+
position: absolute;
|
|
180
|
+
right: calc(var(--space-1, 0.25rem) / 2);
|
|
181
|
+
bottom: calc(var(--space-1, 0.25rem) / 2);
|
|
182
|
+
width: 0.5rem;
|
|
183
|
+
height: 0.5rem;
|
|
184
|
+
border-radius: 999px;
|
|
185
|
+
pointer-events: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.hk-icon-group-item-marker-success {
|
|
189
|
+
background: rgb(var(--color-success, 14 184 64));
|
|
190
|
+
box-shadow: 0 0 0 0.125rem rgb(var(--color-surface, 240 244 248));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Slider items already establish position:relative for the thumb stack —
|
|
194
|
+
* keep the marker above the gliding pill, not under it. */
|
|
195
|
+
.hk-icon-group-slider .hk-icon-group-item .hk-icon-group-item-marker {
|
|
196
|
+
z-index: 1;
|
|
197
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
5
5
|
|
|
6
6
|
import HkIconButtonGroup from "./HkIconButtonGroup";
|
|
@@ -303,3 +303,41 @@ describe("HkIconButtonGroup", () => {
|
|
|
303
303
|
});
|
|
304
304
|
});
|
|
305
305
|
});
|
|
306
|
+
describe("option markers", () => {
|
|
307
|
+
const baseOpts = (marker?: "success", disabled?: boolean) => [
|
|
308
|
+
{ key: "totp", label: "TOTP" },
|
|
309
|
+
{ key: "password", label: "Password", marker, disabled },
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
it("renders the success marker dot and data-marker attribute", () => {
|
|
313
|
+
const c = mountComp(() => h(HkIconButtonGroup, {
|
|
314
|
+
mode: "single",
|
|
315
|
+
variant: "slider",
|
|
316
|
+
options: baseOpts("success"),
|
|
317
|
+
modelValue: "totp",
|
|
318
|
+
}));
|
|
319
|
+
const item = c.querySelector('.hk-icon-group-item[data-key="password"]') as HTMLElement;
|
|
320
|
+
expect(item).toBeTruthy();
|
|
321
|
+
expect(item.getAttribute("data-marker")).toBe("success");
|
|
322
|
+
expect(item.querySelector(".hk-icon-group-item-marker-success")).toBeTruthy();
|
|
323
|
+
// The untouched sibling carries no marker.
|
|
324
|
+
const sibling = c.querySelector('.hk-icon-group-item[data-key="totp"]') as HTMLElement;
|
|
325
|
+
expect(sibling.getAttribute("data-marker")).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("ignores activation of a disabled marked option", async () => {
|
|
329
|
+
const onSelect = vi.fn();
|
|
330
|
+
const c = mountComp(() => h(HkIconButtonGroup, {
|
|
331
|
+
mode: "single",
|
|
332
|
+
variant: "slider",
|
|
333
|
+
options: baseOpts("success", true),
|
|
334
|
+
modelValue: "totp",
|
|
335
|
+
onSelect,
|
|
336
|
+
}));
|
|
337
|
+
const item = c.querySelector('.hk-icon-group-item[data-key="password"]') as HTMLButtonElement;
|
|
338
|
+
expect(item.getAttribute("disabled")).not.toBeNull();
|
|
339
|
+
item.click();
|
|
340
|
+
await Promise.resolve();
|
|
341
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -17,6 +17,11 @@ export interface HkIconButtonGroupOption {
|
|
|
17
17
|
* host's vue differs. Missing icons fall back to the label's initial. */
|
|
18
18
|
icon?: unknown;
|
|
19
19
|
disabled?: boolean;
|
|
20
|
+
/** Status dot pinned to the item's bottom-right corner. `"success"`
|
|
21
|
+
* draws a small green dot (step-proven factor, completed requirement);
|
|
22
|
+
* the dot is decorative — pair it with `disabled` and an updated
|
|
23
|
+
* tooltip when it means "this one is done". */
|
|
24
|
+
marker?: "success";
|
|
20
25
|
/** Tooltip text override; an EMPTY string suppresses the tooltip. */
|
|
21
26
|
tooltip?: string;
|
|
22
27
|
}
|
|
@@ -168,6 +173,7 @@ export default defineComponent({
|
|
|
168
173
|
class="hk-icon-group-item"
|
|
169
174
|
data-key={option.key}
|
|
170
175
|
data-active={active || undefined}
|
|
176
|
+
data-marker={option.marker || undefined}
|
|
171
177
|
role={props.mode === "single" ? "radio" : undefined}
|
|
172
178
|
aria-checked={props.mode === "single" ? active : undefined}
|
|
173
179
|
aria-pressed={props.mode === "multiple" ? active : undefined}
|
|
@@ -185,6 +191,12 @@ export default defineComponent({
|
|
|
185
191
|
</span>
|
|
186
192
|
)}
|
|
187
193
|
</span>
|
|
194
|
+
{option.marker && (
|
|
195
|
+
<span
|
|
196
|
+
class={`hk-icon-group-item-marker hk-icon-group-item-marker-${option.marker}`}
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
/>
|
|
199
|
+
)}
|
|
188
200
|
</button>
|
|
189
201
|
);
|
|
190
202
|
// An empty tooltip override suppresses the wrapper entirely.
|
|
@@ -166,4 +166,57 @@ describe("HkSignInCard", () => {
|
|
|
166
166
|
expect(c.querySelector(".s-auth-methods")).toBeTruthy();
|
|
167
167
|
expect(c.querySelector(".methods-probe")).toBeTruthy();
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
// ── Account-first mode (passwordField: false) ─────────────────────────
|
|
171
|
+
|
|
172
|
+
it("omits the password field entirely in account-first mode", () => {
|
|
173
|
+
const c = mount(h(HkSignInCard, { title: "T", passwordField: false }));
|
|
174
|
+
expect(usernameField(c)).toBeTruthy();
|
|
175
|
+
expect(passwordField(c)).toBeNull();
|
|
176
|
+
// No hidden input may survive either: a password-type field on a form
|
|
177
|
+
// that never carries a password would still trip credential-manager
|
|
178
|
+
// save prompts.
|
|
179
|
+
expect(c.querySelector('input[type="password"]')).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("submits with an empty password once only the account name is typed", async () => {
|
|
183
|
+
const onSubmit = vi.fn();
|
|
184
|
+
const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
|
|
185
|
+
const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
|
|
186
|
+
// Empty username still gates the submit.
|
|
187
|
+
expect(btn.getAttribute("disabled")).not.toBeNull();
|
|
188
|
+
typeInto(usernameField(c), "demiurge");
|
|
189
|
+
await Promise.resolve();
|
|
190
|
+
expect(btn.getAttribute("disabled")).toBeNull(); // no password expected
|
|
191
|
+
btn.click();
|
|
192
|
+
await Promise.resolve();
|
|
193
|
+
await Promise.resolve();
|
|
194
|
+
expect(onSubmit).toHaveBeenCalledWith("demiurge", "");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("does not submit on Enter with an empty username in account-first mode", async () => {
|
|
198
|
+
const onSubmit = vi.fn();
|
|
199
|
+
const c = mount(h(HkSignInCard, { title: "T", passwordField: false, onSubmit }));
|
|
200
|
+
usernameField(c).dispatchEvent(
|
|
201
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
|
202
|
+
);
|
|
203
|
+
await Promise.resolve();
|
|
204
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("seeds the username field from initialUsername", () => {
|
|
208
|
+
const c = mount(
|
|
209
|
+
h(HkSignInCard, { title: "T", passwordField: false, initialUsername: " demiurge " }),
|
|
210
|
+
);
|
|
211
|
+
expect((usernameField(c) as HTMLInputElement).value).toBe(" demiurge ");
|
|
212
|
+
// And the seeded name alone satisfies the submit guard.
|
|
213
|
+
const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
|
|
214
|
+
expect(btn.getAttribute("disabled")).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("keeps the password guard when passwordField is unset (default true)", () => {
|
|
218
|
+
const c = mount(h(HkSignInCard, { title: "T", initialUsername: "demiurge" }));
|
|
219
|
+
const btn = c.querySelector(".hk-btn-primary") as HTMLElement;
|
|
220
|
+
expect(btn.getAttribute("disabled")).not.toBeNull(); // password still empty
|
|
221
|
+
});
|
|
169
222
|
});
|
|
@@ -28,6 +28,12 @@ import HkAuthSubmitButton from "./HkAuthSubmitButton";
|
|
|
28
28
|
* - `usernamePlaceholder` / `usernameType` — override the username field
|
|
29
29
|
* (e.g. email-identifier logins); the placeholder falls back to the
|
|
30
30
|
* `hikari::signIn.usernamePlaceholder` locale when unset.
|
|
31
|
+
* - `passwordField: false` — account-first sign-in: the card collects ONLY
|
|
32
|
+
* the account name and `submit` fires with an empty password. The
|
|
33
|
+
* password itself is proven in the consumer's NEXT step (chest's
|
|
34
|
+
* verify-account panel), where it reappears as one factor among several.
|
|
35
|
+
* Pair with `initialUsername` so the typed name survives the step-flow
|
|
36
|
+
* round-trip (the card remounts when the user navigates back).
|
|
31
37
|
* - `footer` slot — content below the submit button (remember-me,
|
|
32
38
|
* protocol links, …).
|
|
33
39
|
* - `methods` slot — forwarded to HkAuthCard's full-width methods block
|
|
@@ -67,21 +73,31 @@ export const HkSignInCard = defineComponent({
|
|
|
67
73
|
usernameType: { type: String, default: "text" },
|
|
68
74
|
/** Username placeholder; defaults to the hikari::signIn locale. */
|
|
69
75
|
usernamePlaceholder: { type: String, default: undefined },
|
|
76
|
+
/** Account-first flows: hide the password field entirely — the card
|
|
77
|
+
* collects only the account name and `submit` fires with an empty
|
|
78
|
+
* password (the consumer's next step owns the actual proof). Default
|
|
79
|
+
* keeps the classic two-field card. */
|
|
80
|
+
passwordField: { type: Boolean, default: true },
|
|
81
|
+
/** Seed for the username field. Step flows remount this card when the
|
|
82
|
+
* user navigates back from a later step, so the typed account name
|
|
83
|
+
* must travel through a prop rather than module state. */
|
|
84
|
+
initialUsername: { type: String, default: "" },
|
|
70
85
|
},
|
|
71
86
|
emits: {
|
|
72
|
-
/** Fired on explicit click or Enter; never with empty
|
|
87
|
+
/** Fired on explicit click or Enter; never with an empty username (or
|
|
88
|
+
* an empty password while `passwordField` is on) or while busy. */
|
|
73
89
|
submit: (_username: string, _password: string) => true,
|
|
74
90
|
},
|
|
75
91
|
setup(props, { emit, slots }) {
|
|
76
92
|
const { t } = useI18n();
|
|
77
|
-
const username = ref(
|
|
93
|
+
const username = ref(props.initialUsername);
|
|
78
94
|
const password = ref("");
|
|
79
95
|
|
|
80
96
|
function attemptSubmit() {
|
|
81
97
|
if (props.loading || props.disabled) return;
|
|
82
98
|
const u = username.value.trim();
|
|
83
|
-
if (!u || !password.value) return;
|
|
84
|
-
emit("submit", u, password.value);
|
|
99
|
+
if (!u || (props.passwordField && !password.value)) return;
|
|
100
|
+
emit("submit", u, props.passwordField ? password.value : "");
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
return () => (
|
|
@@ -132,21 +148,27 @@ export const HkSignInCard = defineComponent({
|
|
|
132
148
|
),
|
|
133
149
|
}}
|
|
134
150
|
</HkInput>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
151
|
+
{/* Account-first mode omits the field entirely — not hides
|
|
152
|
+
it with CSS: a hidden password input would still plant a
|
|
153
|
+
credential-manager save prompt and an autocomplete target
|
|
154
|
+
on a form that never carries a password. */}
|
|
155
|
+
{props.passwordField && (
|
|
156
|
+
<HkInput
|
|
157
|
+
variant="password"
|
|
158
|
+
modelValue={password.value}
|
|
159
|
+
onUpdate:modelValue={(v: string) => (password.value = v)}
|
|
160
|
+
name="signin-password"
|
|
161
|
+
autocomplete={props.passwordAutocomplete ?? credentialAutocomplete("password", "current-password")}
|
|
162
|
+
placeholder={t("hikari::signIn.passwordPlaceholder", "Password")}
|
|
163
|
+
disabled={props.loading || props.disabled}
|
|
164
|
+
submitOnEnter={attemptSubmit}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
145
167
|
<HkAuthSubmitButton
|
|
146
168
|
label={props.submitLabel ?? t("hikari::signIn.submit", "Sign in")}
|
|
147
169
|
block
|
|
148
170
|
loading={props.loading}
|
|
149
|
-
disabled={props.disabled || !username.value.trim() || !password.value}
|
|
171
|
+
disabled={props.disabled || !username.value.trim() || (props.passwordField && !password.value)}
|
|
150
172
|
doSubmit={() => Promise.resolve(attemptSubmit())}
|
|
151
173
|
/>
|
|
152
174
|
</form>
|