@celestia-island/hikari 0.55.226 → 0.55.228
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/HkAdminHeader.tsx +28 -32
- package/src/components/HkAvatarButton.test.tsx +155 -0
- package/src/components/HkAvatarButton.tsx +77 -0
- package/src/components/HkPopupSelect.mobile.test.tsx +18 -0
- package/src/components/HkPopupSelect.scss +32 -0
- package/src/components/HkPopupSelect.test.tsx +47 -0
- package/src/components/HkPopupSelect.tsx +19 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { computed, defineComponent, ref, type PropType, type VNode } from "vue";
|
|
1
|
+
import { computed, defineComponent, ref, watch, type PropType, type VNode } from "vue";
|
|
2
2
|
import { Camera, ExternalLink, Languages, LogOut, Menu } from "lucide-vue-next";
|
|
3
3
|
import { HBadge, HButton, HSpinner } from "@celestia-island/hikari";
|
|
4
4
|
import { useI18n } from "../i18n/context";
|
|
5
5
|
import HkMenu, { type HkMenuItem } from "./HkMenu";
|
|
6
|
+
import { HkAvatarButton } from "./HkAvatarButton";
|
|
6
7
|
|
|
7
8
|
export interface LocaleOption {
|
|
8
9
|
code: string;
|
|
@@ -93,7 +94,16 @@ export const HkAdminHeader = defineComponent({
|
|
|
93
94
|
const userMenuOpen = ref(false);
|
|
94
95
|
const userTriggerRef = ref<HTMLElement>();
|
|
95
96
|
const avatarModalOpen = ref(false);
|
|
96
|
-
|
|
97
|
+
// The DROPDOWN identity image's own failed-load latch, with the same
|
|
98
|
+
// URL-change reset the trigger's latch has inside HkAvatarButton —
|
|
99
|
+
// an avatar re-upload or account switch must heal BOTH faces (R2's
|
|
100
|
+
// S2 on #909: without the reset, the trigger showed the new image
|
|
101
|
+
// while the chip stayed on its letter fallback forever).
|
|
102
|
+
const menuAvatarFailed = ref(false);
|
|
103
|
+
watch(
|
|
104
|
+
() => props.avatarUrl,
|
|
105
|
+
() => { menuAvatarFailed.value = false; },
|
|
106
|
+
);
|
|
97
107
|
|
|
98
108
|
// The identity cluster is INJECTED, not assumed (2026-10-24): the
|
|
99
109
|
// trigger renders only when the host actually supplied an identity
|
|
@@ -219,35 +229,14 @@ export const HkAdminHeader = defineComponent({
|
|
|
219
229
|
</HButton>
|
|
220
230
|
)}
|
|
221
231
|
{identityMounted.value && (
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
<HkAvatarButton
|
|
233
|
+
avatarUrl={props.avatarUrl}
|
|
234
|
+
username={props.username}
|
|
235
|
+
label={props.avatarTriggerLabel ?? t("hikari::adminHeader.avatarTrigger", "Account menu")}
|
|
236
|
+
popup={props.avatarAction === "drawer" ? "dialog" : "menu"}
|
|
237
|
+
expanded={props.avatarAction === "menu" ? userMenuOpen.value : undefined}
|
|
227
238
|
onClick={onAvatarClick}
|
|
228
|
-
|
|
229
|
-
{/* Deliberately NO hover veil here: the trigger opens the user
|
|
230
|
-
* MENU (the avatar edit lives one row down as the Camera
|
|
231
|
-
* item), so a camera overlay would promise click-to-change
|
|
232
|
-
* and lie (user report 2026-09-16). */}
|
|
233
|
-
{props.avatarUrl && !avatarFailed.value ? (
|
|
234
|
-
<img
|
|
235
|
-
src={props.avatarUrl}
|
|
236
|
-
alt={props.username}
|
|
237
|
-
class="s-admin-header-avatar-img"
|
|
238
|
-
onError={() => { avatarFailed.value = true; }}
|
|
239
|
-
/>
|
|
240
|
-
) : (
|
|
241
|
-
// Letter fallback on the shared avatar primitive — the
|
|
242
|
-
// same filled-circle grammar as the chat frontend's
|
|
243
|
-
// trigger. The old border-2 ring made the console's
|
|
244
|
-
// avatar read as a different control (user report
|
|
245
|
-
// 2026-09-12: 前后台头像不一致).
|
|
246
|
-
<span class="s-user-avatar">
|
|
247
|
-
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
248
|
-
</span>
|
|
249
|
-
)}
|
|
250
|
-
</button>
|
|
239
|
+
/>
|
|
251
240
|
)}
|
|
252
241
|
{/* The reserved workspace-switcher slot (drawer face only).
|
|
253
242
|
The chat header's left wing is avatar + ws-trigger button,
|
|
@@ -292,6 +281,13 @@ export const HkAdminHeader = defineComponent({
|
|
|
292
281
|
<HSpinner size="sm" />
|
|
293
282
|
<div class="s-user-header-email">{props.signingInLabel ?? t("hikari::adminHeader.signingIn", "Signing in…")}</div>
|
|
294
283
|
</div>
|
|
284
|
+
) : slots.identity ? (
|
|
285
|
+
// Host-owned identity block (shell-unify wave 7): hosts
|
|
286
|
+
// with a richer identity vocabulary (localized group
|
|
287
|
+
// names, account-type badges, copy badges) inject their
|
|
288
|
+
// ONE component here; the built-in block below stays for
|
|
289
|
+
// hosts without one.
|
|
290
|
+
slots.identity()
|
|
295
291
|
) : (
|
|
296
292
|
// Identity block FIRST — avatar, nickname, login email,
|
|
297
293
|
// permission badges — the same grammar the chat
|
|
@@ -299,12 +295,12 @@ export const HkAdminHeader = defineComponent({
|
|
|
299
295
|
// s-user-header profile variant).
|
|
300
296
|
<div class="s-user-header s-user-header--profile">
|
|
301
297
|
<span class="s-user-avatar-chip" aria-hidden="true">
|
|
302
|
-
{props.avatarUrl && !
|
|
298
|
+
{props.avatarUrl && !menuAvatarFailed.value ? (
|
|
303
299
|
<img
|
|
304
300
|
src={props.avatarUrl}
|
|
305
301
|
alt=""
|
|
306
302
|
class="s-user-avatar-img"
|
|
307
|
-
onError={() => {
|
|
303
|
+
onError={() => { menuAvatarFailed.value = true; }}
|
|
308
304
|
/>
|
|
309
305
|
) : (
|
|
310
306
|
<span class="s-user-avatar">
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkAvatarButton } from "./HkAvatarButton";
|
|
5
|
+
import { HkAdminHeader } from "./HkAdminHeader";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkAvatarButton — the extracted avatar trigger (shell-unify wave 7).
|
|
9
|
+
*
|
|
10
|
+
* What this file pins:
|
|
11
|
+
* 1. The shared grammar: --space-28 round trigger class, the image with
|
|
12
|
+
* letter fallback, and the failed-load latch that RESETS when the URL
|
|
13
|
+
* changes (an account switch deserves a fresh attempt).
|
|
14
|
+
* 2. Attr fallthrough: a host's data-* attribute lands on the button so
|
|
15
|
+
* host CSS keyed on it keeps working.
|
|
16
|
+
* 3. The aria bindings the menu engines read (haspopup/expanded).
|
|
17
|
+
* 4. HkAdminHeader renders it internally — and its new `identity` slot
|
|
18
|
+
* replaces the built-in dropdown identity block while the pending
|
|
19
|
+
* branch stays built-in.
|
|
20
|
+
*
|
|
21
|
+
* (Repo test convention: raw createApp + container queries, no
|
|
22
|
+
* @vue/test-utils dependency.)
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
26
|
+
|
|
27
|
+
function mount(node: ReturnType<typeof h>) {
|
|
28
|
+
const container = document.createElement("div");
|
|
29
|
+
document.body.appendChild(container);
|
|
30
|
+
const app = createApp({ render: () => node });
|
|
31
|
+
app.mount(container);
|
|
32
|
+
mounts.push({ app, container });
|
|
33
|
+
return container;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
38
|
+
app.unmount();
|
|
39
|
+
container.remove();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function trigger(c: HTMLElement): HTMLButtonElement {
|
|
44
|
+
return c.querySelector("button.s-admin-header-avatar") as HTMLButtonElement;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("HkAvatarButton", () => {
|
|
48
|
+
it("renders the letter fallback when no URL is given", () => {
|
|
49
|
+
const c = mount(h(HkAvatarButton, { username: "yuzu", label: "Account menu" }));
|
|
50
|
+
const btn = trigger(c);
|
|
51
|
+
expect(btn).toBeTruthy();
|
|
52
|
+
expect(btn.getAttribute("aria-label")).toBe("Account menu");
|
|
53
|
+
expect(btn.querySelector(".s-user-avatar")?.textContent).toBe("Y");
|
|
54
|
+
expect(btn.querySelector("img")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the image when a URL is given, with the username as alt", () => {
|
|
58
|
+
const c = mount(h(HkAvatarButton, { username: "yuzu", avatarUrl: "/a.png" }));
|
|
59
|
+
const img = trigger(c).querySelector("img.s-admin-header-avatar-img") as HTMLImageElement;
|
|
60
|
+
expect(img).toBeTruthy();
|
|
61
|
+
expect(img.getAttribute("src")).toBe("/a.png");
|
|
62
|
+
expect(img.getAttribute("alt")).toBe("yuzu");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to the letter when the image fails, and a URL change on the SAME instance retries", async () => {
|
|
66
|
+
// Same-instance reactive update (R3 M1 on #909): remounting proves
|
|
67
|
+
// nothing (a fresh instance starts unlatched), so the host re-renders
|
|
68
|
+
// the button with a new URL while it stays mounted — the reset watch
|
|
69
|
+
// is what must bring the image back.
|
|
70
|
+
const url = ref("/a.png");
|
|
71
|
+
const Host = defineComponent({
|
|
72
|
+
setup: () => () => h(HkAvatarButton, { username: "yuzu", avatarUrl: url.value }),
|
|
73
|
+
});
|
|
74
|
+
const c = mount(h(Host));
|
|
75
|
+
const firstImg = trigger(c).querySelector("img") as HTMLImageElement;
|
|
76
|
+
firstImg.dispatchEvent(new Event("error"));
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(trigger(c).querySelector(".s-user-avatar")?.textContent).toBe("Y");
|
|
79
|
+
url.value = "/b.png";
|
|
80
|
+
await nextTick();
|
|
81
|
+
await nextTick();
|
|
82
|
+
const retryImg = trigger(c).querySelector("img") as HTMLImageElement | null;
|
|
83
|
+
expect(retryImg, "the latch reset — the new URL gets a fresh attempt").toBeTruthy();
|
|
84
|
+
expect(retryImg!.getAttribute("src")).toBe("/b.png");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("forwards extra attrs onto the button (host data-* keys keep working)", () => {
|
|
88
|
+
const c = mount(h(HkAvatarButton, { username: "y", "data-image": "/a.png" } as never));
|
|
89
|
+
expect(trigger(c).getAttribute("data-image")).toBe("/a.png");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("emits click and binds the menu aria pair", async () => {
|
|
93
|
+
let fired = 0;
|
|
94
|
+
const c = mount(h(HkAvatarButton, {
|
|
95
|
+
username: "yuzu",
|
|
96
|
+
label: "Account menu",
|
|
97
|
+
popup: "menu",
|
|
98
|
+
expanded: false,
|
|
99
|
+
onClick: () => { fired += 1; },
|
|
100
|
+
}));
|
|
101
|
+
const btn = trigger(c);
|
|
102
|
+
expect(btn.getAttribute("aria-haspopup")).toBe("menu");
|
|
103
|
+
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
|
104
|
+
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
105
|
+
await nextTick();
|
|
106
|
+
expect(fired).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("HkAdminHeader × HkAvatarButton / identity slot", () => {
|
|
111
|
+
it("never renders aria-expanded when no expanded prop is given (drawer-mode contract)", () => {
|
|
112
|
+
// R2 S3-2: menu engines read this pair; a drawer-mode trigger has no
|
|
113
|
+
// open state, so the attribute must be ABSENT (not "false").
|
|
114
|
+
const c = mount(h(HkAvatarButton, { username: "y", label: "Open drawer", popup: "dialog" }));
|
|
115
|
+
expect(trigger(c).hasAttribute("aria-expanded")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("renders the shared trigger internally (behavior parity with the inline past)", () => {
|
|
119
|
+
const c = mount(h(HkAdminHeader, { username: "yuzu", title: "Dashboard" }));
|
|
120
|
+
const btn = trigger(c);
|
|
121
|
+
expect(btn).toBeTruthy();
|
|
122
|
+
expect(btn.querySelector(".s-user-avatar")?.textContent).toBe("Y");
|
|
123
|
+
// The wing keeps the ws-slot reservation contract on the drawer face.
|
|
124
|
+
expect(c.querySelector(".s-admin-header-user")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("an identity slot replaces the built-in dropdown block; the pending branch stays built-in", async () => {
|
|
128
|
+
const opened = h(HkAdminHeader, {
|
|
129
|
+
username: "yuzu",
|
|
130
|
+
userEmail: "y@zu",
|
|
131
|
+
title: "D",
|
|
132
|
+
}, {
|
|
133
|
+
identity: () => h("div", { class: "host-identity" }, "HOST-IDENTITY"),
|
|
134
|
+
});
|
|
135
|
+
// Open the dropdown by clicking the trigger.
|
|
136
|
+
const c = mount(opened);
|
|
137
|
+
trigger(c).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
138
|
+
await nextTick();
|
|
139
|
+
await nextTick();
|
|
140
|
+
const host = document.body.querySelector(".host-identity");
|
|
141
|
+
expect(host, "the host identity block renders in the open menu").toBeTruthy();
|
|
142
|
+
expect(document.body.querySelector(".s-user-header--profile")).toBeNull();
|
|
143
|
+
|
|
144
|
+
// Pending identity (no username/email): built-in spinner branch —
|
|
145
|
+
// the host slot is for identity PRESENTATION, not the fetch race.
|
|
146
|
+
const c2 = mount(h(HkAdminHeader, {
|
|
147
|
+
signingInLabel: "Signing in…",
|
|
148
|
+
onForceSignOut: () => {},
|
|
149
|
+
}));
|
|
150
|
+
trigger(c2).dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
151
|
+
await nextTick();
|
|
152
|
+
await nextTick();
|
|
153
|
+
expect(document.body.querySelector(".s-user-header--pending")).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineComponent, ref, watch, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HkAvatarButton — the avatar trigger button, ONE component for every
|
|
5
|
+
* face (extracted from HkAdminHeader, shell-unify wave 7, 2026-10-29).
|
|
6
|
+
*
|
|
7
|
+
* The button owns the shared avatar grammar: the `--space-28` round
|
|
8
|
+
* trigger, the image with its graceful letter fallback, and the
|
|
9
|
+
* failed-load latch that resets when the URL changes (an account switch
|
|
10
|
+
* or avatar upload deserves a fresh attempt). HkAdminHeader renders it
|
|
11
|
+
* internally; hosts with their own header chrome (the chat frontend's
|
|
12
|
+
* left wing) render it directly — the geometry has exactly one
|
|
13
|
+
* definition (`.s-admin-header-avatar` in admin-tokens) instead of one
|
|
14
|
+
* per face.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NO hover veil: every face's trigger opens the user MENU
|
|
17
|
+
* (the avatar edit lives one row down as the Camera item), so a camera
|
|
18
|
+
* overlay would promise click-to-change and lie (user report
|
|
19
|
+
* 2026-09-16).
|
|
20
|
+
*
|
|
21
|
+
* Extra attrs (data-image, testids, …) fall through to the button
|
|
22
|
+
* element (single root, default inheritAttrs) so host CSS keyed on them
|
|
23
|
+
* keeps working.
|
|
24
|
+
*/
|
|
25
|
+
export const HkAvatarButton = defineComponent({
|
|
26
|
+
name: "HkAvatarButton",
|
|
27
|
+
props: {
|
|
28
|
+
/** Avatar image URL; empty renders the letter fallback. */
|
|
29
|
+
avatarUrl: { type: String, default: "" },
|
|
30
|
+
/** Signed-in name: the fallback letter, and the image's alt. */
|
|
31
|
+
username: { type: String, default: "" },
|
|
32
|
+
/** Accessible label for the trigger (what it opens, not who it is). */
|
|
33
|
+
label: { type: String, default: undefined },
|
|
34
|
+
/** `aria-haspopup` value: "menu" (dropdown) or "dialog" (drawer). */
|
|
35
|
+
popup: { type: String as PropType<"menu" | "dialog" | undefined>, default: undefined },
|
|
36
|
+
/** `aria-expanded`; omit for drawer-mode triggers (no open state). */
|
|
37
|
+
expanded: { type: Boolean, default: undefined },
|
|
38
|
+
},
|
|
39
|
+
emits: {
|
|
40
|
+
click: (_e: MouseEvent) => true,
|
|
41
|
+
},
|
|
42
|
+
setup(props, { emit }) {
|
|
43
|
+
const avatarFailed = ref(false);
|
|
44
|
+
// A new URL deserves a fresh attempt (account switch, avatar upload)
|
|
45
|
+
// — the same self-heal the identity chips apply.
|
|
46
|
+
watch(
|
|
47
|
+
() => props.avatarUrl,
|
|
48
|
+
() => { avatarFailed.value = false; },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return () => (
|
|
52
|
+
<button
|
|
53
|
+
class="s-admin-header-avatar"
|
|
54
|
+
aria-label={props.label}
|
|
55
|
+
aria-haspopup={props.popup}
|
|
56
|
+
aria-expanded={props.expanded}
|
|
57
|
+
onClick={(e: MouseEvent) => { emit("click", e); }}
|
|
58
|
+
>
|
|
59
|
+
{props.avatarUrl && !avatarFailed.value ? (
|
|
60
|
+
<img
|
|
61
|
+
src={props.avatarUrl}
|
|
62
|
+
alt={props.username}
|
|
63
|
+
class="s-admin-header-avatar-img"
|
|
64
|
+
onError={() => { avatarFailed.value = true; }}
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
// Letter fallback on the shared avatar primitive — the same
|
|
68
|
+
// filled-circle grammar on every face (user report 2026-09-12:
|
|
69
|
+
// 前后台头像不一致).
|
|
70
|
+
<span class="s-user-avatar">
|
|
71
|
+
{props.username?.charAt(0).toUpperCase() || "?"}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
</button>
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -31,6 +31,10 @@ afterEach(() => {
|
|
|
31
31
|
|
|
32
32
|
const OPTIONS = [{ value: "a1", label: "Agent One" }];
|
|
33
33
|
|
|
34
|
+
const DESCRIBED_OPTIONS = [
|
|
35
|
+
{ value: "a1", label: "Agent One", description: "Personal · /srv/one" },
|
|
36
|
+
];
|
|
37
|
+
|
|
34
38
|
describe("HkPopupSelect (mobile sheet)", () => {
|
|
35
39
|
it("hideLabel keeps the sheet's breadcrumb title on the field's name", async () => {
|
|
36
40
|
// hideLabel only suppresses the INLINE caption; the sheet header
|
|
@@ -47,4 +51,18 @@ describe("HkPopupSelect (mobile sheet)", () => {
|
|
|
47
51
|
expect(title, "the sheet renders a title band").toBeTruthy();
|
|
48
52
|
expect(title!.textContent).toBe("Agent");
|
|
49
53
|
});
|
|
54
|
+
|
|
55
|
+
it("described options keep their second line inside the sheet", async () => {
|
|
56
|
+
const c = mount(
|
|
57
|
+
h(HkPopupSelect, { modelValue: "", options: DESCRIBED_OPTIONS }),
|
|
58
|
+
);
|
|
59
|
+
const trigger = c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!;
|
|
60
|
+
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
61
|
+
await Promise.resolve();
|
|
62
|
+
await Promise.resolve();
|
|
63
|
+
const desc = document.body.querySelector(
|
|
64
|
+
".hk-popover-panel .hk-popup-select-item-desc",
|
|
65
|
+
);
|
|
66
|
+
expect(desc?.textContent).toBe("Personal · /srv/one");
|
|
67
|
+
});
|
|
50
68
|
});
|
|
@@ -149,6 +149,38 @@
|
|
|
149
149
|
@include mi.item;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/* Two-line option rows, rendered ONLY for options that carry a
|
|
153
|
+
* description — a stock row keeps the bare label span (wrap allowed, no
|
|
154
|
+
* optical shift) so existing consumers see byte-identical geometry (R2).
|
|
155
|
+
* The body owns the row's flex growth so both lines ellipsis against the
|
|
156
|
+
* same edge. */
|
|
157
|
+
.hk-popup-select-item-body {
|
|
158
|
+
flex: 1;
|
|
159
|
+
min-width: 0;
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
gap: var(--space-2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.hk-popup-select-item-label {
|
|
166
|
+
@include mi.label;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* The secondary line inherits the row color (hover/checked tints flow
|
|
170
|
+
* through) and steps down via opacity + the caption size, so it stays
|
|
171
|
+
* clearly meta against both the plain and the primary checked face. */
|
|
172
|
+
.hk-popup-select-item-desc {
|
|
173
|
+
overflow: hidden;
|
|
174
|
+
text-overflow: ellipsis;
|
|
175
|
+
white-space: nowrap;
|
|
176
|
+
font-size: var(--text-xs);
|
|
177
|
+
font-weight: 400;
|
|
178
|
+
color: inherit;
|
|
179
|
+
opacity: 0.62;
|
|
180
|
+
/* Same italic-overhang clipping room the label mixin reserves. */
|
|
181
|
+
padding-inline-end: var(--hk-menu-item-label-cushion);
|
|
182
|
+
}
|
|
183
|
+
|
|
152
184
|
/* Keyboard highlight face yields to checked/active; the hover face comes
|
|
153
185
|
* from mi.item's own guarded :hover (identical values, higher specificity). */
|
|
154
186
|
.hk-popup-select-item[data-highlighted]:not([data-checked]):not([data-active]) {
|
|
@@ -81,4 +81,51 @@ describe("HkPopupSelect", () => {
|
|
|
81
81
|
const c = mount(h(HkPopupSelect, { modelValue: "a1", label: "Agent", options: OPTIONS }));
|
|
82
82
|
expect(c.querySelector(".hk-popup-select-trigger")!.getAttribute("aria-label")).toBeNull();
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it("an option description renders its own muted line under the label", async () => {
|
|
86
|
+
const c = mount(
|
|
87
|
+
h(HkPopupSelect, {
|
|
88
|
+
modelValue: "",
|
|
89
|
+
options: [{ value: "a1", label: "Agent One", description: "Personal · /srv/one" }],
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
// Open the popout — option rows live in the anchored panel, which
|
|
93
|
+
// mounts across a microtask (the mobile sheet tests await the same).
|
|
94
|
+
c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!
|
|
95
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
96
|
+
await Promise.resolve();
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
const item = document.body.querySelector(".hk-popup-select-item")!;
|
|
99
|
+
expect(item.textContent).toContain("Agent One");
|
|
100
|
+
const desc = item.querySelector(".hk-popup-select-item-desc");
|
|
101
|
+
expect(desc?.textContent).toBe("Personal · /srv/one");
|
|
102
|
+
// The label sits in its own span so the two lines never merge.
|
|
103
|
+
expect(item.querySelector(".hk-popup-select-item-label")?.textContent).toBe("Agent One");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("no description → the exact stock DOM: bare label span, no body wrapper", async () => {
|
|
107
|
+
const c = mount(h(HkPopupSelect, { modelValue: "", options: OPTIONS }));
|
|
108
|
+
c.querySelector<HTMLButtonElement>(".hk-popup-select-trigger")!
|
|
109
|
+
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
await Promise.resolve();
|
|
112
|
+
const item = document.body.querySelector(".hk-popup-select-item")!;
|
|
113
|
+
// R2 contract: description-less rows render the pre-description DOM —
|
|
114
|
+
// no two-line anatomy, so no mi.label ellipsis/optical spec applies.
|
|
115
|
+
expect(item.querySelector(".hk-popup-select-item-body")).toBeNull();
|
|
116
|
+
expect(item.querySelector(".hk-popup-select-item-label")).toBeNull();
|
|
117
|
+
expect(item.querySelector("span")?.textContent).toBe("Agent One");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("the collapsed trigger shows the label only, never the description", () => {
|
|
121
|
+
const c = mount(
|
|
122
|
+
h(HkPopupSelect, {
|
|
123
|
+
modelValue: "a1",
|
|
124
|
+
options: [{ value: "a1", label: "Agent One", description: "secret-meta" }],
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
const value = c.querySelector(".hk-popup-select-value")!;
|
|
128
|
+
expect(value.textContent).toBe("Agent One");
|
|
129
|
+
expect(value.textContent).not.toContain("secret-meta");
|
|
130
|
+
});
|
|
84
131
|
});
|
|
@@ -9,6 +9,15 @@ import "./HkPopupSelect.scss";
|
|
|
9
9
|
export interface HkPopupSelectOption {
|
|
10
10
|
value: string;
|
|
11
11
|
label: string;
|
|
12
|
+
/** Secondary line under the label, inside the same option row — a
|
|
13
|
+
* muted, single-line ellipsis strip for meta text (a row's owner,
|
|
14
|
+
* address, type) that must stay visible without leaking into the
|
|
15
|
+
* collapsed trigger, which keeps showing `label` alone. Absent → the
|
|
16
|
+
* row renders the EXACT stock DOM (bare label span, wrap allowed, no
|
|
17
|
+
* optical shift): the two-line anatomy exists only when there is a
|
|
18
|
+
* second line to show (R2: the mi.label ellipsis + 0.5px spec is a
|
|
19
|
+
* behavior change stock rows must not inherit silently). */
|
|
20
|
+
description?: string;
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
const activePopupId = ref<string | null>(null);
|
|
@@ -232,7 +241,16 @@ export default defineComponent({
|
|
|
232
241
|
onClick={() => select(opt.value)}
|
|
233
242
|
onPointerenter={() => { highlightedIndex.value = i; }}
|
|
234
243
|
>
|
|
235
|
-
|
|
244
|
+
{opt.description ? (
|
|
245
|
+
<span class="hk-popup-select-item-body">
|
|
246
|
+
<span class="hk-popup-select-item-label">{opt.label}</span>
|
|
247
|
+
<span class="hk-popup-select-item-desc">{opt.description}</span>
|
|
248
|
+
</span>
|
|
249
|
+
) : (
|
|
250
|
+
// Stock rows keep the exact pre-description DOM: no
|
|
251
|
+
// body wrapper, no mi.label spec — the R2 contract.
|
|
252
|
+
<span>{opt.label}</span>
|
|
253
|
+
)}
|
|
236
254
|
</div>
|
|
237
255
|
))}
|
|
238
256
|
</HkScrollContainer>
|
package/src/index.ts
CHANGED
|
@@ -760,6 +760,7 @@ export { HkErrorReportingOverlay as HkErrorReportingOverlay } from "./errorRepor
|
|
|
760
760
|
export { HkAdminShell as HkAdminShell } from "./components/HkAdminShell";
|
|
761
761
|
export { HkFaceShell as HkFaceShell } from "./components/HkFaceShell";
|
|
762
762
|
export { HkAdminHeader as HkAdminHeader } from "./components/HkAdminHeader";
|
|
763
|
+
export { HkAvatarButton as HkAvatarButton } from "./components/HkAvatarButton";
|
|
763
764
|
export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
|
|
764
765
|
export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
|
|
765
766
|
export { HkThemePanelContent as HkThemePanelContent } from "./components/HkThemePanelContent";
|