@celestia-island/hikari 0.55.131 → 0.55.132

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.55.131",
3
+ "version": "0.55.132",
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",
@@ -0,0 +1,184 @@
1
+ // HkUserPicker (components/HkUserPicker.tsx) — the tag field + search
2
+ // dialog pair. Tokens follow the house scale (--space-*, --radius-*,
3
+ // rgb(var(--color-*)) — the HkKeywordSearchModal.scss conventions).
4
+
5
+ .hk-user-picker {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--space-8, 0.5rem);
9
+
10
+ &.is-disabled {
11
+ opacity: 0.55;
12
+ pointer-events: none;
13
+ }
14
+ }
15
+
16
+ .hk-user-picker-label {
17
+ font-size: 0.75rem;
18
+ color: rgb(var(--color-text) / 70%);
19
+ }
20
+
21
+ .hk-user-picker-field {
22
+ display: flex;
23
+ flex-wrap: wrap;
24
+ align-items: center;
25
+ gap: var(--space-8, 0.5rem);
26
+ min-height: 2.5rem;
27
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
28
+ background: var(--hi-color-surface, rgba(240, 244, 248, 0.95));
29
+ border: 1px solid var(--hk-kw-border, rgb(var(--color-border) / 8%));
30
+ border-radius: var(--radius-lg, 0.75rem);
31
+ cursor: pointer;
32
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
33
+
34
+ &:focus-visible {
35
+ outline: none;
36
+ border-color: var(--hi-color-primary, #7aa2f7);
37
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--hi-color-primary, #7aa2f7) 12%, transparent);
38
+ }
39
+ }
40
+
41
+ .hk-user-picker-tag {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ gap: var(--space-4, 0.25rem);
45
+ max-width: 100%;
46
+ padding: var(--space-4, 0.25rem) var(--space-8, 0.5rem);
47
+ border-radius: var(--radius-md, 0.5rem);
48
+ background: rgb(var(--color-primary) / 12%);
49
+ border: 1px solid rgb(var(--color-primary) / 20%);
50
+ }
51
+
52
+ .hk-user-picker-tag-body {
53
+ display: flex;
54
+ flex-direction: column;
55
+ min-width: 0;
56
+ }
57
+
58
+ .hk-user-picker-tag-name {
59
+ font-size: 0.75rem;
60
+ font-weight: 600;
61
+ color: rgb(var(--color-text));
62
+ white-space: nowrap;
63
+ overflow: hidden;
64
+ text-overflow: ellipsis;
65
+ }
66
+
67
+ .hk-user-picker-tag-email {
68
+ font-size: 0.625rem;
69
+ color: rgb(var(--color-text) / 60%);
70
+ white-space: nowrap;
71
+ overflow: hidden;
72
+ text-overflow: ellipsis;
73
+ }
74
+
75
+ .hk-user-picker-tag-x {
76
+ flex: none;
77
+ display: inline-flex;
78
+ align-items: center;
79
+ justify-content: center;
80
+ width: 1rem;
81
+ height: 1rem;
82
+ border: none;
83
+ border-radius: 999px;
84
+ background: transparent;
85
+ color: rgb(var(--color-text) / 60%);
86
+ font-size: 0.875rem;
87
+ line-height: 1;
88
+ cursor: pointer;
89
+
90
+ &:hover {
91
+ background: rgb(var(--color-text) / 10%);
92
+ color: rgb(var(--color-text));
93
+ }
94
+ }
95
+
96
+ .hk-user-picker-add {
97
+ font-size: 0.75rem;
98
+ color: rgb(var(--color-text) / 60%);
99
+
100
+ &.is-placeholder {
101
+ color: rgb(var(--color-text) / 45%);
102
+ }
103
+ }
104
+
105
+ .hk-user-picker-dialog {
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: var(--space-12, 0.75rem);
109
+ padding: var(--space-4, 0.25rem) 0;
110
+ }
111
+
112
+ .hk-user-picker-rows {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: var(--space-4, 0.25rem);
116
+ min-height: 6rem;
117
+ }
118
+
119
+ .hk-user-picker-note {
120
+ margin: 0;
121
+ padding: var(--space-12, 0.75rem) 0;
122
+ font-size: 0.75rem;
123
+ color: rgb(var(--color-text) / 55%);
124
+ text-align: center;
125
+ }
126
+
127
+ .hk-user-picker-row {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: space-between;
131
+ gap: var(--space-8, 0.5rem);
132
+ width: 100%;
133
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
134
+ background: transparent;
135
+ border: 1px solid transparent;
136
+ border-radius: var(--radius-md, 0.5rem);
137
+ text-align: left;
138
+ cursor: pointer;
139
+
140
+ &:hover {
141
+ background: rgb(var(--color-text) / 6%);
142
+ }
143
+
144
+ &.is-selected {
145
+ background: rgb(var(--color-primary) / 12%);
146
+ border-color: rgb(var(--color-primary) / 25%);
147
+ }
148
+ }
149
+
150
+ .hk-user-picker-row-main {
151
+ display: flex;
152
+ flex-direction: column;
153
+ min-width: 0;
154
+ }
155
+
156
+ .hk-user-picker-row-name {
157
+ font-size: 0.8125rem;
158
+ font-weight: 600;
159
+ color: rgb(var(--color-text));
160
+ white-space: nowrap;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ }
164
+
165
+ .hk-user-picker-row-email {
166
+ font-size: 0.6875rem;
167
+ color: rgb(var(--color-text) / 60%);
168
+ white-space: nowrap;
169
+ overflow: hidden;
170
+ text-overflow: ellipsis;
171
+ }
172
+
173
+ .hk-user-picker-row-check {
174
+ flex: none;
175
+ color: var(--hi-color-primary, #7aa2f7);
176
+ font-weight: 700;
177
+ }
178
+
179
+ .hk-user-picker-count {
180
+ margin: 0;
181
+ font-size: 0.6875rem;
182
+ color: rgb(var(--color-text) / 55%);
183
+ text-align: right;
184
+ }
@@ -0,0 +1,208 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import HkUserPicker, { type HkUserOption } from "./HkUserPicker";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(renderNode: () => ReturnType<typeof h>) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: renderNode });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ const alice: HkUserOption = { uid: "u-1", username: "alice", email: "alice@example.com", displayName: "Alice" };
25
+ const bob: HkUserOption = { uid: "u-2", username: "bob", email: "bob@example.com" };
26
+ const carol: HkUserOption = { uid: "u-3", username: "carol", email: "carol@example.com", displayName: "Carol" };
27
+
28
+ function rows() {
29
+ return [...document.body.querySelectorAll<HTMLElement>(".hk-user-picker-row")];
30
+ }
31
+
32
+ describe("HkUserPicker", () => {
33
+ it("renders the value as tags showing the display name and email", () => {
34
+ mount(() => h(HkUserPicker, { modelValue: [alice, bob], search: async () => [] }));
35
+ const tags = [...document.body.querySelectorAll(".hk-user-picker-tag")];
36
+ expect(tags).toHaveLength(2);
37
+ expect(tags[0].textContent).toContain("Alice");
38
+ expect(tags[0].textContent).toContain("alice@example.com");
39
+ // No display name → the username is the tag title.
40
+ expect(tags[1].textContent).toContain("bob");
41
+ });
42
+
43
+ it("removing a tag emits the value without that user", async () => {
44
+ const onUpdate = vi.fn();
45
+ mount(() => h(HkUserPicker, {
46
+ modelValue: [alice, bob],
47
+ search: async () => [],
48
+ "onUpdate:modelValue": (users: HkUserOption[]) => onUpdate(users),
49
+ }));
50
+ const x = document.body.querySelector<HTMLButtonElement>(".hk-user-picker-tag-x");
51
+ x!.click();
52
+ await nextTick();
53
+ expect(onUpdate).toHaveBeenCalledWith([bob]);
54
+ });
55
+
56
+ it("opening the dialog runs the injected search with the empty query", async () => {
57
+ const search = vi.fn(async () => [alice, bob]);
58
+ mount(() => h(HkUserPicker, { modelValue: [], search }));
59
+ document.body.querySelector<HTMLElement>(".hk-user-picker-field")!.click();
60
+ await nextTick();
61
+ await nextTick();
62
+ expect(search).toHaveBeenCalledWith("");
63
+ expect(rows()).toHaveLength(2);
64
+ });
65
+
66
+ it("single mode emits one user on pick", async () => {
67
+ const onUpdate = vi.fn();
68
+ mount(() => h(HkUserPicker, {
69
+ modelValue: [],
70
+ search: async () => [alice, bob],
71
+ "onUpdate:modelValue": (users: HkUserOption[]) => onUpdate(users),
72
+ }));
73
+ document.body.querySelector<HTMLElement>(".hk-user-picker-field")!.click();
74
+ await nextTick();
75
+ await nextTick();
76
+ rows()[1].click();
77
+ await nextTick();
78
+ expect(onUpdate).toHaveBeenCalledWith([bob]);
79
+ });
80
+
81
+ it("multi mode accumulates picks and commits on confirm", async () => {
82
+ const onUpdate = vi.fn();
83
+ mount(() => h(HkUserPicker, {
84
+ modelValue: [],
85
+ mode: "multi",
86
+ search: async () => [alice, bob, carol],
87
+ "onUpdate:modelValue": (users: HkUserOption[]) => onUpdate(users),
88
+ }));
89
+ document.body.querySelector<HTMLElement>(".hk-user-picker-field")!.click();
90
+ await nextTick();
91
+ await nextTick();
92
+ rows()[0].click();
93
+ rows()[2].click();
94
+ // Re-picking a drafted row toggles it OUT again — the de-dup half of
95
+ // pickRow (pinned after the R2 mutation round caught it untested).
96
+ rows()[0].click();
97
+ await nextTick();
98
+ expect(onUpdate).not.toHaveBeenCalled();
99
+ const confirm = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-modal-footer button")]
100
+ .find((b) => b.textContent === "Confirm")!;
101
+ confirm.click();
102
+ await nextTick();
103
+ expect(onUpdate).toHaveBeenCalledWith([carol]);
104
+ });
105
+
106
+ it("a stale search resolving late cannot clobber the newer results", async () => {
107
+ let releaseFirst: ((value: HkUserOption[]) => void) | null = null;
108
+ const first = new Promise<HkUserOption[]>((resolve) => { releaseFirst = resolve; });
109
+ const search = vi.fn()
110
+ .mockImplementationOnce(() => first)
111
+ .mockImplementationOnce(async () => [bob]);
112
+ mount(() => h(HkUserPicker, { modelValue: [], search }));
113
+ document.body.querySelector<HTMLElement>(".hk-user-picker-field")!.click();
114
+ await nextTick();
115
+ // Second query (the component's race token advances)…
116
+ // Reach runSearch directly through the input's search event wiring:
117
+ // HkSearchInput debounces at 250ms; fake timers advance past it.
118
+ vi.useFakeTimers();
119
+ const input = document.body.querySelector<HTMLInputElement>(".hk-user-picker-dialog input");
120
+ input!.value = "bob";
121
+ input!.dispatchEvent(new Event("input"));
122
+ // Flush the modelValue watch so HkSearchInput schedules its debounce
123
+ // timer on the fake clock BEFORE time advances past it.
124
+ await nextTick();
125
+ vi.advanceTimersByTime(300);
126
+ await Promise.resolve();
127
+ await Promise.resolve();
128
+ await nextTick();
129
+ // …then the stale first answer lands LAST — it must not win.
130
+ releaseFirst!([alice]);
131
+ await Promise.resolve();
132
+ await Promise.resolve();
133
+ await nextTick();
134
+ vi.useRealTimers();
135
+ const names = rows().map((r) => r.querySelector(".hk-user-picker-row-name")!.textContent);
136
+ expect(names).toEqual(["bob"]);
137
+ });
138
+
139
+ it("searches again on reopen after a full close (non-empty prior query)", async () => {
140
+ const search = vi.fn(async (q: string) => (q ? [bob] : [alice, bob]));
141
+ mount(() => h(HkUserPicker, { modelValue: [], search }));
142
+ const field = () => document.body.querySelector<HTMLElement>(".hk-user-picker-field")!;
143
+
144
+ // Session 1: open (search #1, ""), type "bob" (search #2), pick — single
145
+ // mode closes the dialog.
146
+ field().click();
147
+ await nextTick(); await nextTick();
148
+ const input = document.body.querySelector<HTMLInputElement>(".hk-user-picker-dialog input")!;
149
+ input.value = "bob";
150
+ input.dispatchEvent(new Event("input"));
151
+ await new Promise((r) => setTimeout(r, 300));
152
+ await nextTick();
153
+ rows()[0].click();
154
+ await nextTick();
155
+
156
+ // FULL close: wait past HkModal's leave window so the dialog content
157
+ // actually unmounts (the precondition of the R3 finding — while still
158
+ // mounted, the input's watcher masked the bug).
159
+ await new Promise((r) => setTimeout(r, 700));
160
+ expect(document.body.querySelector(".hk-user-picker-dialog")).toBeNull();
161
+
162
+ // Session 2: reopen MUST fire the initial-batch search("") again.
163
+ field().click();
164
+ await nextTick(); await nextTick();
165
+ expect(search).toHaveBeenLastCalledWith("");
166
+ expect(search.mock.calls.filter((c) => c[0] === "").length).toBe(2);
167
+ expect(rows()).toHaveLength(2);
168
+ });
169
+
170
+ it("fires exactly one search per open within the modal leave window", async () => {
171
+ const search = vi.fn(async (q: string) => (q ? [bob] : [alice, bob]));
172
+ mount(() => h(HkUserPicker, { modelValue: [], mode: "multi", search }));
173
+ const field = () => document.body.querySelector<HTMLElement>(".hk-user-picker-field")!;
174
+
175
+ // Session 1: open + type.
176
+ field().click();
177
+ await nextTick(); await nextTick();
178
+ const input = document.body.querySelector<HTMLInputElement>(".hk-user-picker-dialog input")!;
179
+ input.value = "bob";
180
+ input.dispatchEvent(new Event("input"));
181
+ await new Promise((r) => setTimeout(r, 300));
182
+ await nextTick();
183
+
184
+ // Close WITHOUT picking: the modal header's own × button.
185
+ const closeBtn = document.body.querySelector<HTMLButtonElement>(".hk-modal-close");
186
+ expect(closeBtn, "modal close button").toBeDefined();
187
+ closeBtn!.click();
188
+ await nextTick();
189
+ // Reopen IMMEDIATELY (inside the leave window — the input may still be
190
+ // mounted): the session-key remount + explicit search("") must yield
191
+ // exactly ONE new empty-query search, no watcher-originated duplicate.
192
+ field().click();
193
+ await nextTick(); await nextTick();
194
+ await new Promise((r) => setTimeout(r, 320));
195
+ const empties = search.mock.calls.filter((c) => c[0] === "");
196
+ expect(empties.length).toBe(2);
197
+ expect(search.mock.calls.filter((c) => c[0] === "bob").length).toBe(1);
198
+ });
199
+
200
+ it("a rejecting search renders the failure note instead of throwing", async () => {
201
+ mount(() => h(HkUserPicker, { modelValue: [], search: async () => { throw new Error("down"); } }));
202
+ document.body.querySelector<HTMLElement>(".hk-user-picker-field")!.click();
203
+ await nextTick();
204
+ await nextTick();
205
+ expect(document.body.querySelector(".hk-user-picker-rows")!.getAttribute("data-state")).toBe("failed");
206
+ expect(rows()).toHaveLength(0);
207
+ });
208
+ });
@@ -0,0 +1,267 @@
1
+ // 2026-10-10 user direction — the ONE user-selection business component.
2
+ //
3
+ // Every surface that needs "pick one or more users" renders this field:
4
+ // selected members travel as tags (display name + email), and picking
5
+ // happens through a search dialog — type a nickname, username or email,
6
+ // the injected `search` callback answers from the backend, click a row.
7
+ // The dialog is HkModal (a centered window on desktop, the bottom sheet
8
+ // on mobile — HkModal's own responsive treatment) with an HkSearchInput
9
+ // as its box. Hosts inject the search itself, so the component carries
10
+ // zero transport: the panel wires its user-list RPC, another host could
11
+ // wire anything that resolves a query to user options.
12
+ //
13
+ // Semantics (the HkAffixPicker vocabulary): "single" closes on pick and
14
+ // replaces the selection; "multi" toggles rows in a working copy and
15
+ // commits on Confirm. The field's tag × removes directly — no dialog.
16
+ import { computed, defineComponent, ref, watch, type PropType } from "vue";
17
+
18
+ import { useI18n } from "../i18n/context";
19
+ import HkModal, { type ModalAction } from "./HkModal";
20
+ import HkSearchInput from "./HkSearchInput";
21
+ import "./HkUserPicker.scss";
22
+
23
+ /** One pickable user. `uid` is the stable identity (what the host stores);
24
+ * `username`/`email`/`displayName`/`avatarUrl` are display baggage the
25
+ * field keeps inside its value so hosts never re-fetch to re-render. */
26
+ export interface HkUserOption {
27
+ uid: string;
28
+ username: string;
29
+ email?: string;
30
+ displayName?: string;
31
+ avatarUrl?: string;
32
+ }
33
+
34
+ /** Primary tag label: the display name when the deployment has one, the
35
+ * username otherwise — never both stacked (the email line follows). */
36
+ function tagTitle(user: HkUserOption): string {
37
+ return user.displayName?.trim() || user.username;
38
+ }
39
+
40
+ export default defineComponent({
41
+ name: "HkUserPicker",
42
+ props: {
43
+ modelValue: { type: Array as PropType<HkUserOption[]>, required: true },
44
+ /** "single" replaces the selection on pick; "multi" accumulates (the
45
+ * HkAffixPicker mode vocabulary). */
46
+ mode: { type: String as PropType<"single" | "multi">, default: "single" },
47
+ /** Backend search: resolve a query (may be empty — the initial batch)
48
+ * to user options. Rejections render as a retry hint, never a throw. */
49
+ search: { type: Function as PropType<(query: string) => Promise<HkUserOption[]>>, required: true },
50
+ label: { type: String, default: undefined },
51
+ placeholder: { type: String, default: undefined },
52
+ disabled: { type: Boolean, default: false },
53
+ /** Rows rendered per query — a runaway backend answer cannot turn the
54
+ * dialog into a thousand-row scroll. */
55
+ maxResults: { type: Number, default: 50 },
56
+ },
57
+ emits: {
58
+ "update:modelValue": (_users: HkUserOption[]) => true,
59
+ },
60
+ setup(props, { emit }) {
61
+ const { t } = useI18n();
62
+
63
+ const open = ref(false);
64
+ const query = ref("");
65
+ const results = ref<HkUserOption[]>([]);
66
+ const loading = ref(false);
67
+ const failed = ref(false);
68
+ /** Multi-mode working copy — committed only on Confirm. */
69
+ const draft = ref<HkUserOption[]>([]);
70
+ /** Race guard: only the newest in-flight search may write results. */
71
+ let searchToken = 0;
72
+
73
+ async function runSearch(q: string) {
74
+ const token = ++searchToken;
75
+ loading.value = true;
76
+ failed.value = false;
77
+ try {
78
+ const found = await props.search(q);
79
+ if (token !== searchToken) return;
80
+ results.value = (found ?? []).slice(0, props.maxResults);
81
+ } catch {
82
+ if (token !== searchToken) return;
83
+ results.value = [];
84
+ failed.value = true;
85
+ } finally {
86
+ if (token === searchToken) loading.value = false;
87
+ }
88
+ }
89
+
90
+ const openDialog = () => {
91
+ if (props.disabled) return;
92
+ open.value = true;
93
+ };
94
+
95
+ // One search per open, always: the input is keyed per session, so it
96
+ // REMOUNTS on every open (a fresh mount receives modelValue "" and
97
+ // its no-immediate watch fires nothing; the remounted instance also
98
+ // drops any in-flight debounce from the previous session), and the
99
+ // explicit runSearch("") below is the one initial batch. Reopening
100
+ // after a full close MUST search too — HkModal unmounts its content
101
+ // when closed, so no watcher would ever fire (the R3 finding).
102
+ const sessionSeq = ref(0);
103
+ watch(open, (now) => {
104
+ if (!now) return;
105
+ sessionSeq.value += 1;
106
+ query.value = "";
107
+ results.value = [];
108
+ draft.value = [...props.modelValue];
109
+ void runSearch("");
110
+ });
111
+
112
+ const onSearch = (q: string) => {
113
+ void runSearch(q);
114
+ };
115
+
116
+ const inDraft = (uid: string) => draft.value.some((u) => u.uid === uid);
117
+
118
+ const pickRow = (user: HkUserOption) => {
119
+ if (props.mode === "single") {
120
+ open.value = false;
121
+ emit("update:modelValue", [user]);
122
+ return;
123
+ }
124
+ draft.value = inDraft(user.uid)
125
+ ? draft.value.filter((u) => u.uid !== user.uid)
126
+ : [...draft.value, user];
127
+ };
128
+
129
+ const confirmDraft = () => {
130
+ open.value = false;
131
+ emit("update:modelValue", draft.value);
132
+ };
133
+
134
+ const removeTag = (uid: string) => {
135
+ if (props.disabled) return;
136
+ emit(
137
+ "update:modelValue",
138
+ props.modelValue.filter((u) => u.uid !== uid),
139
+ );
140
+ };
141
+
142
+ const dialogTitle = computed(
143
+ () => props.label ?? t("hikari::userPicker.title", "Select users"),
144
+ );
145
+
146
+ const dialogActions = computed<ModalAction[] | undefined>(() =>
147
+ props.mode === "multi"
148
+ ? [
149
+ {
150
+ label: t("hikari::messageBox.cancel", "Cancel"),
151
+ variant: "secondary",
152
+ onClick: () => { open.value = false; },
153
+ },
154
+ {
155
+ label: t("hikari::userPicker.confirm", "Confirm"),
156
+ variant: "primary",
157
+ disabled: draft.value.length === 0,
158
+ onClick: confirmDraft,
159
+ },
160
+ ]
161
+ : undefined,
162
+ );
163
+
164
+ return () => (
165
+ <div class={{ "hk-user-picker": true, "is-disabled": props.disabled }}>
166
+ {props.label && <div class="hk-user-picker-label">{props.label}</div>}
167
+ <div
168
+ class="hk-user-picker-field"
169
+ role="button"
170
+ tabindex={props.disabled ? -1 : 0}
171
+ aria-disabled={props.disabled}
172
+ aria-label={props.label ?? t("hikari::userPicker.title", "Select users")}
173
+ onClick={openDialog}
174
+ onKeydown={(e: KeyboardEvent) => {
175
+ if (props.disabled) return;
176
+ if (e.key === "Enter" || e.key === " ") {
177
+ e.preventDefault();
178
+ openDialog();
179
+ }
180
+ }}
181
+ >
182
+ {props.modelValue.map((user) => (
183
+ <span key={user.uid} class="hk-user-picker-tag" title={`${tagTitle(user)} ${user.email ?? ""}`.trim()}>
184
+ <span class="hk-user-picker-tag-body">
185
+ <span class="hk-user-picker-tag-name">{tagTitle(user)}</span>
186
+ {user.email && <span class="hk-user-picker-tag-email">{user.email}</span>}
187
+ </span>
188
+ {!props.disabled && (
189
+ <button
190
+ type="button"
191
+ class="hk-user-picker-tag-x"
192
+ aria-label={`${t("hikari::userPicker.remove", "Remove")} ${tagTitle(user)}`}
193
+ onClick={(e: MouseEvent) => {
194
+ e.stopPropagation();
195
+ removeTag(user.uid);
196
+ }}
197
+ >
198
+ ×
199
+ </button>
200
+ )}
201
+ </span>
202
+ ))}
203
+ <span class={{ "hk-user-picker-add": true, "is-placeholder": props.modelValue.length === 0 }}>
204
+ {props.modelValue.length === 0
205
+ ? (props.placeholder ?? t("hikari::userPicker.add", "Select users…"))
206
+ : t("hikari::userPicker.add", "Select users…")}
207
+ </span>
208
+ </div>
209
+
210
+ <HkModal
211
+ modelValue={open.value}
212
+ onUpdate:modelValue={(v: boolean) => { open.value = v; }}
213
+ title={dialogTitle.value}
214
+ width="30rem"
215
+ footerActions={dialogActions.value}
216
+ >
217
+ <div class="hk-user-picker-dialog">
218
+ <HkSearchInput
219
+ key={sessionSeq.value}
220
+ modelValue={query.value}
221
+ onUpdate:modelValue={(v: string) => { query.value = v; }}
222
+ onSearch={onSearch}
223
+ debounce={250}
224
+ placeholder={t("hikari::userPicker.searchPlaceholder", "Nickname, username or email")}
225
+ />
226
+ <div class="hk-user-picker-rows" data-state={loading.value ? "loading" : failed.value ? "failed" : results.value.length === 0 ? "empty" : "ready"}>
227
+ {loading.value && (
228
+ <p class="hk-user-picker-note">{t("hikari::userPicker.searching", "Searching…")}</p>
229
+ )}
230
+ {!loading.value && failed.value && (
231
+ <p class="hk-user-picker-note">{t("hikari::userPicker.searchFailed", "The search failed; try again.")}</p>
232
+ )}
233
+ {!loading.value && !failed.value && results.value.length === 0 && (
234
+ <p class="hk-user-picker-note">
235
+ {query.value.trim()
236
+ ? t("hikari::userPicker.empty", "No users match this search.")
237
+ : t("hikari::userPicker.emptyInitial", "Type a nickname, username or email to search.")}
238
+ </p>
239
+ )}
240
+ {!loading.value && results.value.map((user) => (
241
+ <button
242
+ key={user.uid}
243
+ type="button"
244
+ class={{ "hk-user-picker-row": true, "is-selected": props.mode === "multi" && inDraft(user.uid) }}
245
+ onClick={() => pickRow(user)}
246
+ >
247
+ <span class="hk-user-picker-row-main">
248
+ <span class="hk-user-picker-row-name">{tagTitle(user)}</span>
249
+ {user.email && <span class="hk-user-picker-row-email">{user.email}</span>}
250
+ </span>
251
+ {props.mode === "multi" && inDraft(user.uid) && (
252
+ <span class="hk-user-picker-row-check" aria-hidden>✓</span>
253
+ )}
254
+ </button>
255
+ ))}
256
+ </div>
257
+ {props.mode === "multi" && draft.value.length > 0 && (
258
+ <p class="hk-user-picker-count">
259
+ {t("hikari::userPicker.selectedCount", "{count} selected").replace("{count}", String(draft.value.length))}
260
+ </p>
261
+ )}
262
+ </div>
263
+ </HkModal>
264
+ </div>
265
+ );
266
+ },
267
+ });
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "العودة لتسجيل الدخول",
263
263
  "hikari::mfaVerify.loading": "جارٍ التحميل...",
264
264
  "hikari::board3d.resetView": "إعادة تعيين العرض",
265
- "hikari::board3d.unavailable": "العرض ثلاثي الأبعاد غير متوفر على هذا الجهاز"
265
+ "hikari::board3d.unavailable": "العرض ثلاثي الأبعاد غير متوفر على هذا الجهاز",
266
+ "hikari::userPicker.title": "اختيار المستخدمين",
267
+ "hikari::userPicker.searchPlaceholder": "الاسم المستعار أو اسم المستخدم أو البريد الإلكتروني",
268
+ "hikari::userPicker.searching": "جارٍ البحث…",
269
+ "hikari::userPicker.emptyInitial": "اكتب اسمًا مستعارًا أو اسم مستخدم أو بريدًا إلكترونيًا للبحث.",
270
+ "hikari::userPicker.empty": "لا يوجد مستخدمون مطابقون لهذا البحث.",
271
+ "hikari::userPicker.searchFailed": "فشل البحث؛ حاول مرة أخرى.",
272
+ "hikari::userPicker.add": "اختيار المستخدمين…",
273
+ "hikari::userPicker.remove": "إزالة",
274
+ "hikari::userPicker.confirm": "تأكيد",
275
+ "hikari::userPicker.selectedCount": "تم اختيار {count}"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Zurück zur Anmeldung",
263
263
  "hikari::mfaVerify.loading": "Laden...",
264
264
  "hikari::board3d.resetView": "Ansicht zurücksetzen",
265
- "hikari::board3d.unavailable": "3D ist auf diesem Gerät nicht verfügbar"
265
+ "hikari::board3d.unavailable": "3D ist auf diesem Gerät nicht verfügbar",
266
+ "hikari::userPicker.title": "Benutzer auswählen",
267
+ "hikari::userPicker.searchPlaceholder": "Spitzname, Benutzername oder E-Mail",
268
+ "hikari::userPicker.searching": "Suche…",
269
+ "hikari::userPicker.emptyInitial": "Gib einen Spitznamen, Benutzernamen oder eine E-Mail ein, um zu suchen.",
270
+ "hikari::userPicker.empty": "Keine Benutzer entsprechen dieser Suche.",
271
+ "hikari::userPicker.searchFailed": "Die Suche ist fehlgeschlagen; versuche es erneut.",
272
+ "hikari::userPicker.add": "Benutzer auswählen…",
273
+ "hikari::userPicker.remove": "Entfernen",
274
+ "hikari::userPicker.confirm": "Bestätigen",
275
+ "hikari::userPicker.selectedCount": "{count} ausgewählt"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Back to sign in",
263
263
  "hikari::mfaVerify.loading": "Loading...",
264
264
  "hikari::board3d.resetView": "Reset view",
265
- "hikari::board3d.unavailable": "3D is unavailable on this device"
265
+ "hikari::board3d.unavailable": "3D is unavailable on this device",
266
+ "hikari::userPicker.title": "Select users",
267
+ "hikari::userPicker.searchPlaceholder": "Nickname, username or email",
268
+ "hikari::userPicker.searching": "Searching…",
269
+ "hikari::userPicker.emptyInitial": "Type a nickname, username or email to search.",
270
+ "hikari::userPicker.empty": "No users match this search.",
271
+ "hikari::userPicker.searchFailed": "The search failed; try again.",
272
+ "hikari::userPicker.add": "Select users…",
273
+ "hikari::userPicker.remove": "Remove",
274
+ "hikari::userPicker.confirm": "Confirm",
275
+ "hikari::userPicker.selectedCount": "{count} selected"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Volver al inicio de sesión",
263
263
  "hikari::mfaVerify.loading": "Cargando...",
264
264
  "hikari::board3d.resetView": "Restablecer vista",
265
- "hikari::board3d.unavailable": "El 3D no está disponible en este dispositivo"
265
+ "hikari::board3d.unavailable": "El 3D no está disponible en este dispositivo",
266
+ "hikari::userPicker.title": "Seleccionar usuarios",
267
+ "hikari::userPicker.searchPlaceholder": "Apodo, nombre de usuario o correo",
268
+ "hikari::userPicker.searching": "Buscando…",
269
+ "hikari::userPicker.emptyInitial": "Escribe un apodo, nombre de usuario o correo para buscar.",
270
+ "hikari::userPicker.empty": "Ningún usuario coincide con esta búsqueda.",
271
+ "hikari::userPicker.searchFailed": "La búsqueda falló; inténtalo de nuevo.",
272
+ "hikari::userPicker.add": "Seleccionar usuarios…",
273
+ "hikari::userPicker.remove": "Quitar",
274
+ "hikari::userPicker.confirm": "Confirmar",
275
+ "hikari::userPicker.selectedCount": "{count} seleccionados"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Retour à la connexion",
263
263
  "hikari::mfaVerify.loading": "Chargement...",
264
264
  "hikari::board3d.resetView": "Réinitialiser la vue",
265
- "hikari::board3d.unavailable": "La 3D n'est pas disponible sur cet appareil"
265
+ "hikari::board3d.unavailable": "La 3D n'est pas disponible sur cet appareil",
266
+ "hikari::userPicker.title": "Sélectionner des utilisateurs",
267
+ "hikari::userPicker.searchPlaceholder": "Pseudo, nom d'utilisateur ou e-mail",
268
+ "hikari::userPicker.searching": "Recherche…",
269
+ "hikari::userPicker.emptyInitial": "Saisissez un pseudo, un nom d'utilisateur ou un e-mail pour rechercher.",
270
+ "hikari::userPicker.empty": "Aucun utilisateur ne correspond à cette recherche.",
271
+ "hikari::userPicker.searchFailed": "La recherche a échoué ; réessayez.",
272
+ "hikari::userPicker.add": "Sélectionner des utilisateurs…",
273
+ "hikari::userPicker.remove": "Retirer",
274
+ "hikari::userPicker.confirm": "Confirmer",
275
+ "hikari::userPicker.selectedCount": "{count} sélectionnés"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "ログインに戻る",
263
263
  "hikari::mfaVerify.loading": "読み込み中...",
264
264
  "hikari::board3d.resetView": "視点をリセット",
265
- "hikari::board3d.unavailable": "このデバイスでは 3D を利用できません"
265
+ "hikari::board3d.unavailable": "このデバイスでは 3D を利用できません",
266
+ "hikari::userPicker.title": "ユーザーを選択",
267
+ "hikari::userPicker.searchPlaceholder": "ニックネーム、ユーザー名またはメールアドレス",
268
+ "hikari::userPicker.searching": "検索中…",
269
+ "hikari::userPicker.emptyInitial": "ニックネーム、ユーザー名またはメールアドレスを入力して検索してください。",
270
+ "hikari::userPicker.empty": "この検索に一致するユーザーはいません。",
271
+ "hikari::userPicker.searchFailed": "検索に失敗しました。もう一度お試しください。",
272
+ "hikari::userPicker.add": "ユーザーを選択…",
273
+ "hikari::userPicker.remove": "削除",
274
+ "hikari::userPicker.confirm": "確認",
275
+ "hikari::userPicker.selectedCount": "{count} 件選択中"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "로그인으로 돌아가기",
263
263
  "hikari::mfaVerify.loading": "로딩 중...",
264
264
  "hikari::board3d.resetView": "시점 재설정",
265
- "hikari::board3d.unavailable": "이 기기에서는 3D를 사용할 수 없습니다"
265
+ "hikari::board3d.unavailable": "이 기기에서는 3D를 사용할 수 없습니다",
266
+ "hikari::userPicker.title": "사용자 선택",
267
+ "hikari::userPicker.searchPlaceholder": "닉네임, 사용자 이름 또는 이메일",
268
+ "hikari::userPicker.searching": "검색 중…",
269
+ "hikari::userPicker.emptyInitial": "닉네임, 사용자 이름 또는 이메일을 입력해 검색하세요.",
270
+ "hikari::userPicker.empty": "이 검색과 일치하는 사용자가 없습니다.",
271
+ "hikari::userPicker.searchFailed": "검색에 실패했습니다. 다시 시도하세요.",
272
+ "hikari::userPicker.add": "사용자 선택…",
273
+ "hikari::userPicker.remove": "제거",
274
+ "hikari::userPicker.confirm": "확인",
275
+ "hikari::userPicker.selectedCount": "{count}명 선택됨"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Voltar ao login",
263
263
  "hikari::mfaVerify.loading": "Carregando...",
264
264
  "hikari::board3d.resetView": "Redefinir visualização",
265
- "hikari::board3d.unavailable": "O 3D não está disponível neste dispositivo"
265
+ "hikari::board3d.unavailable": "O 3D não está disponível neste dispositivo",
266
+ "hikari::userPicker.title": "Selecionar usuários",
267
+ "hikari::userPicker.searchPlaceholder": "Apelido, nome de usuário ou e-mail",
268
+ "hikari::userPicker.searching": "Pesquisando…",
269
+ "hikari::userPicker.emptyInitial": "Digite um apelido, nome de usuário ou e-mail para pesquisar.",
270
+ "hikari::userPicker.empty": "Nenhum usuário corresponde a esta pesquisa.",
271
+ "hikari::userPicker.searchFailed": "A pesquisa falhou; tente novamente.",
272
+ "hikari::userPicker.add": "Selecionar usuários…",
273
+ "hikari::userPicker.remove": "Remover",
274
+ "hikari::userPicker.confirm": "Confirmar",
275
+ "hikari::userPicker.selectedCount": "{count} selecionados"
266
276
  }
@@ -262,5 +262,15 @@
262
262
  "hikari::mfaVerify.back": "Вернуться к входу",
263
263
  "hikari::mfaVerify.loading": "Загрузка...",
264
264
  "hikari::board3d.resetView": "Сбросить вид",
265
- "hikari::board3d.unavailable": "3D недоступно на этом устройстве"
265
+ "hikari::board3d.unavailable": "3D недоступно на этом устройстве",
266
+ "hikari::userPicker.title": "Выбрать пользователей",
267
+ "hikari::userPicker.searchPlaceholder": "Ник, имя пользователя или эл. почта",
268
+ "hikari::userPicker.searching": "Поиск…",
269
+ "hikari::userPicker.emptyInitial": "Введите ник, имя пользователя или эл. почту для поиска.",
270
+ "hikari::userPicker.empty": "Нет пользователей, соответствующих этому запросу.",
271
+ "hikari::userPicker.searchFailed": "Поиск не удался; попробуйте ещё раз.",
272
+ "hikari::userPicker.add": "Выбрать пользователей…",
273
+ "hikari::userPicker.remove": "Убрать",
274
+ "hikari::userPicker.confirm": "Подтвердить",
275
+ "hikari::userPicker.selectedCount": "Выбрано: {count}"
266
276
  }
@@ -266,5 +266,15 @@
266
266
  "hikari::mfaVerify.back": "返回重新登录",
267
267
  "hikari::mfaVerify.loading": "加载中...",
268
268
  "hikari::board3d.resetView": "重置视角",
269
- "hikari::board3d.unavailable": "此设备不支持 3D 显示"
269
+ "hikari::board3d.unavailable": "此设备不支持 3D 显示",
270
+ "hikari::userPicker.title": "选择用户",
271
+ "hikari::userPicker.searchPlaceholder": "昵称、用户名或邮箱",
272
+ "hikari::userPicker.searching": "搜索中…",
273
+ "hikari::userPicker.emptyInitial": "输入昵称、用户名或邮箱开始搜索。",
274
+ "hikari::userPicker.empty": "没有符合搜索的用户。",
275
+ "hikari::userPicker.searchFailed": "搜索失败,请重试。",
276
+ "hikari::userPicker.add": "选择用户…",
277
+ "hikari::userPicker.remove": "移除",
278
+ "hikari::userPicker.confirm": "确认",
279
+ "hikari::userPicker.selectedCount": "已选 {count} 人"
270
280
  }
@@ -266,5 +266,15 @@
266
266
  "hikari::mfaVerify.back": "返回重新登入",
267
267
  "hikari::mfaVerify.loading": "加載中...",
268
268
  "hikari::board3d.resetView": "重設視角",
269
- "hikari::board3d.unavailable": "此裝置不支援 3D 顯示"
269
+ "hikari::board3d.unavailable": "此裝置不支援 3D 顯示",
270
+ "hikari::userPicker.title": "選擇使用者",
271
+ "hikari::userPicker.searchPlaceholder": "暱稱、使用者名稱或電子郵件",
272
+ "hikari::userPicker.searching": "搜尋中…",
273
+ "hikari::userPicker.emptyInitial": "輸入暱稱、使用者名稱或電子郵件開始搜尋。",
274
+ "hikari::userPicker.empty": "沒有符合搜尋的使用者。",
275
+ "hikari::userPicker.searchFailed": "搜尋失敗,請重試。",
276
+ "hikari::userPicker.add": "選擇使用者…",
277
+ "hikari::userPicker.remove": "移除",
278
+ "hikari::userPicker.confirm": "確認",
279
+ "hikari::userPicker.selectedCount": "已選 {count} 人"
270
280
  }
package/src/index.ts CHANGED
@@ -715,6 +715,7 @@ export { default as HkNavItem } from "./components/HkNavItem";
715
715
  export { default as HkNumberInput } from "./components/HkNumberInput";
716
716
  export { default as HkPhoneInput } from "./components/HkPhoneInput";
717
717
  export { default as HkAffixPicker } from "./components/HkAffixPicker";
718
+ export { default as HkUserPicker, type HkUserOption } from "./components/HkUserPicker";
718
719
  export { default as HkPhaseTransition } from "./components/HkPhaseTransition";
719
720
  export { default as HkGaugeRing } from "./components/HkGaugeRing";
720
721
  export { default as HkProgressRing } from "./components/HkProgressRing";