@celestia-island/hikari 0.32.4 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.32.4",
3
+ "version": "0.34.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,268 @@
1
+ /* HkAffixPicker — the shared "affix chip + searchable popup list".
2
+ * Field hosts drop this into an HkInput prefix/suffix slot; the chip
3
+ * visuals come from the host through the scoped chip slot (hosts layer
4
+ * their own class on top via chipClass), while the popup — search,
5
+ * selected tags, option rows, custom row — is fully standardized here. */
6
+
7
+ /* ── chip base ────────────────────────────────────────────────────────
8
+ * Neutral clickable chip; hosts override visually via their own class
9
+ * (HkPhoneInput's dial chip, HkLocalizedInput's badge chip, …). */
10
+ .hk-affix-chip {
11
+ display: inline-flex;
12
+ align-items: center;
13
+ gap: 0.3rem;
14
+ max-width: 11rem;
15
+ padding: 2px 6px 2px 4px;
16
+ border: none;
17
+ border-radius: var(--radius-sm, 6px);
18
+ background: transparent;
19
+ color: rgb(var(--color-text));
20
+ cursor: pointer;
21
+ transition: background 0.12s ease, color 0.12s ease;
22
+
23
+ &:hover:not(:disabled),
24
+ &:focus-visible:not(:disabled) {
25
+ background: rgb(var(--color-primary) / 14%);
26
+ }
27
+
28
+ &:disabled {
29
+ cursor: default;
30
+ opacity: 0.6;
31
+ }
32
+ }
33
+
34
+ .hk-affix-chip-label {
35
+ font-weight: 600;
36
+ font-size: var(--text-sm, 14px);
37
+ white-space: nowrap;
38
+ overflow: hidden;
39
+ text-overflow: ellipsis;
40
+ }
41
+
42
+ .hk-affix-chip-caret {
43
+ flex: none;
44
+ color: rgb(var(--color-muted));
45
+ }
46
+
47
+ /* ── popup head: tags (multi) + search ─────────────────────────────── */
48
+ .hk-affix-head {
49
+ display: flex;
50
+ flex-direction: column;
51
+ }
52
+
53
+ .hk-affix-search {
54
+ padding: 8px 10px 6px;
55
+ }
56
+
57
+ .hk-affix-tags {
58
+ padding: 10px 10px 8px;
59
+ border-bottom: 1px solid var(--border-faint);
60
+ }
61
+
62
+ .hk-affix-tag-list {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 6px;
66
+ min-width: min(18rem, 100%);
67
+ max-width: 24rem;
68
+ }
69
+
70
+ /* One selected entry: [flag] label (meta) •active-dot ×]
71
+ * The × opens the shared confirm message box — the dialog's Confirm is
72
+ * what actually erases; the × alone never does. */
73
+ .hk-affix-tag {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 8px;
77
+ min-height: 2.25rem;
78
+ padding: 4px var(--space-16);
79
+ border: 1px solid var(--border-subtle);
80
+ border-radius: var(--radius-md);
81
+ background: rgb(var(--color-surface) / var(--opacity-half, 50%));
82
+ transition: border-color 0.12s ease, background 0.12s ease;
83
+
84
+ &:hover {
85
+ border-color: rgb(var(--color-primary) / 40%);
86
+ }
87
+
88
+ &[data-active] {
89
+ border-color: rgb(var(--color-primary) / 45%);
90
+ background: rgb(var(--color-primary) / 12%);
91
+
92
+ .hk-affix-tag-text {
93
+ font-weight: 600;
94
+ }
95
+ }
96
+
97
+ }
98
+
99
+ .hk-affix-tag-body {
100
+ flex: 1;
101
+ min-width: 0;
102
+ display: inline-flex;
103
+ align-items: center;
104
+ gap: 6px;
105
+ padding: 0;
106
+ border: 0;
107
+ background: transparent;
108
+ color: rgb(var(--color-text));
109
+ font-size: var(--text-sm);
110
+ line-height: 1.4;
111
+ text-align: left;
112
+ cursor: pointer;
113
+ appearance: none;
114
+ }
115
+
116
+ .hk-affix-tag-flag {
117
+ flex-shrink: 0;
118
+ font-size: var(--text-sm);
119
+ line-height: 1;
120
+ font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
121
+ sans-serif;
122
+ }
123
+
124
+ .hk-affix-tag-text {
125
+ min-width: 0;
126
+ overflow: hidden;
127
+ text-overflow: ellipsis;
128
+ white-space: nowrap;
129
+ }
130
+
131
+ .hk-affix-tag-meta {
132
+ flex-shrink: 0;
133
+ color: rgb(var(--color-muted));
134
+ font-size: calc(var(--text-xs) * 0.92);
135
+ font-variant-numeric: tabular-nums;
136
+ }
137
+
138
+ .hk-affix-tag-dot {
139
+ flex-shrink: 0;
140
+ width: 5px;
141
+ height: 5px;
142
+ border-radius: 50%;
143
+ background: rgb(var(--color-primary));
144
+ }
145
+
146
+ .hk-affix-tag-x {
147
+ flex: none;
148
+ display: inline-flex;
149
+ align-items: center;
150
+ justify-content: center;
151
+ width: 1.5rem;
152
+ height: 1.5rem;
153
+ padding: 0;
154
+ border: 0;
155
+ border-radius: 999px;
156
+ background: transparent;
157
+ color: rgb(var(--color-muted));
158
+ cursor: pointer;
159
+ appearance: none;
160
+ transition: color 0.12s ease, background 0.12s ease;
161
+
162
+ &:hover,
163
+ &:focus-visible {
164
+ color: rgb(var(--color-danger, 240 70 80));
165
+ background: rgb(var(--color-danger, 240 70 80) / 12%);
166
+ }
167
+ }
168
+
169
+ /* ── option rows ────────────────────────────────────────────────────── */
170
+ .hk-affix-list {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 1px;
174
+ padding: 4px 6px 8px;
175
+ min-width: 13rem;
176
+ max-width: min(19rem, calc(100vw - 2rem));
177
+ max-height: 17rem;
178
+ overflow-y: auto;
179
+ }
180
+
181
+ .hk-affix-row {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 8px;
185
+ width: 100%;
186
+ min-height: 2.25rem;
187
+ padding: 4px 8px;
188
+ border: none;
189
+ border-radius: var(--radius-sm, 6px);
190
+ background: transparent;
191
+ color: rgb(var(--color-text));
192
+ font-size: var(--text-sm, 14px);
193
+ text-align: left;
194
+ cursor: pointer;
195
+ appearance: none;
196
+ transition: background 0.1s ease;
197
+
198
+ &:hover,
199
+ &:focus-visible {
200
+ background: rgb(var(--color-primary) / 10%);
201
+ outline: none;
202
+ }
203
+
204
+ &[data-active] {
205
+ background: rgb(var(--color-primary) / 16%);
206
+ font-weight: 600;
207
+ }
208
+
209
+ &[data-disabled] {
210
+ opacity: 0.5;
211
+ cursor: default;
212
+
213
+ &:hover {
214
+ background: transparent;
215
+ }
216
+ }
217
+
218
+ /* The "Use <query>" free-text row reads as an add affordance. */
219
+ &[data-custom="true"] {
220
+ color: rgb(var(--color-primary));
221
+
222
+ .hk-affix-row-label {
223
+ font-weight: 600;
224
+ overflow: hidden;
225
+ text-overflow: ellipsis;
226
+ white-space: nowrap;
227
+ }
228
+ }
229
+ }
230
+
231
+ .hk-affix-row-flag {
232
+ flex: none;
233
+ font-size: calc(var(--text-base, 15px) * 1.05);
234
+ line-height: 1;
235
+ font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
236
+ sans-serif;
237
+ }
238
+
239
+ .hk-affix-row-label {
240
+ flex: 1;
241
+ min-width: 0;
242
+ overflow: hidden;
243
+ text-overflow: ellipsis;
244
+ white-space: nowrap;
245
+ }
246
+
247
+ .hk-affix-row-meta {
248
+ flex: none;
249
+ color: rgb(var(--color-muted));
250
+ font-size: calc(var(--text-sm, 14px) * 0.9);
251
+ font-variant-numeric: tabular-nums;
252
+ }
253
+
254
+ .hk-affix-empty {
255
+ padding: 14px 12px;
256
+ color: rgb(var(--color-muted));
257
+ font-size: var(--text-sm, 14px);
258
+ text-align: center;
259
+ }
260
+
261
+ @media (prefers-reduced-motion: reduce) {
262
+ .hk-affix-chip,
263
+ .hk-affix-row,
264
+ .hk-affix-tag,
265
+ .hk-affix-tag-x {
266
+ transition: none;
267
+ }
268
+ }
@@ -0,0 +1,317 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import HkAffixPicker, { type HkAffixOption } from "./HkAffixPicker";
5
+
6
+ const OPTIONS: HkAffixOption[] = [
7
+ { key: "cn", label: "China", meta: "+86", flag: "🇨🇳", keywords: "zhongguo 中国 0086" },
8
+ { key: "jp", label: "Japan", meta: "+81", flag: "🇯🇵" },
9
+ { key: "us", label: "United States", meta: "+1", flag: "🇺🇸" },
10
+ ];
11
+
12
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
13
+
14
+ interface MountOptions {
15
+ mode?: "single" | "multi";
16
+ selected?: string | string[];
17
+ activeKey?: string;
18
+ allowCustom?: boolean;
19
+ searchable?: boolean;
20
+ closeOnSelect?: boolean;
21
+ confirmRemove?: boolean;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ function mountPicker(opts: MountOptions = {}) {
26
+ const events = {
27
+ select: [] as string[],
28
+ remove: [] as string[],
29
+ custom: [] as string[],
30
+ open: [] as boolean[],
31
+ };
32
+ const container = document.createElement("div");
33
+ document.body.appendChild(container);
34
+ const app = createApp({
35
+ render: () =>
36
+ h(HkAffixPicker, {
37
+ options: OPTIONS,
38
+ mode: opts.mode ?? "single",
39
+ selected: opts.selected ?? "",
40
+ activeKey: opts.activeKey,
41
+ allowCustom: opts.allowCustom ?? false,
42
+ searchable: opts.searchable ?? true,
43
+ closeOnSelect: opts.closeOnSelect,
44
+ confirmRemove: opts.confirmRemove,
45
+ disabled: opts.disabled ?? false,
46
+ onSelect: (key: string) => events.select.push(key),
47
+ onRemove: (key: string) => events.remove.push(key),
48
+ onCustom: (q: string) => events.custom.push(q),
49
+ "onUpdate:open": (v: boolean) => events.open.push(v),
50
+ }),
51
+ });
52
+ app.mount(container);
53
+ mounts.push({ app, container });
54
+ return { events, container };
55
+ }
56
+
57
+ function queryChip(container: HTMLElement): HTMLButtonElement {
58
+ const chip = container.querySelector<HTMLButtonElement>(".hk-affix-chip");
59
+ expect(chip, "affix chip renders").toBeTruthy();
60
+ return chip!;
61
+ }
62
+
63
+ async function openPopup(container: HTMLElement) {
64
+ queryChip(container).click();
65
+ await nextTick();
66
+ await nextTick();
67
+ }
68
+
69
+ function rows(): HTMLButtonElement[] {
70
+ return [...document.querySelectorAll<HTMLButtonElement>(".hk-affix-row")];
71
+ }
72
+
73
+ function tags(): HTMLElement[] {
74
+ return [...document.querySelectorAll<HTMLElement>(".hk-affix-tag")];
75
+ }
76
+
77
+ function tag(label: string): HTMLElement | undefined {
78
+ return tags().find(
79
+ (t) => (t.querySelector(".hk-affix-tag-text")?.textContent ?? "") === label,
80
+ );
81
+ }
82
+
83
+ function searchInput(): HTMLInputElement {
84
+ const input = document.querySelector<HTMLInputElement>(".hk-affix-search input");
85
+ expect(input, "search field renders").toBeTruthy();
86
+ return input!;
87
+ }
88
+
89
+ async function typeQuery(value: string) {
90
+ const input = searchInput();
91
+ input.value = value;
92
+ input.dispatchEvent(new Event("input", { bubbles: true }));
93
+ await nextTick();
94
+ await nextTick();
95
+ }
96
+
97
+ async function untilSettled(check: () => number): Promise<void> {
98
+ const deadline = Date.now() + 800;
99
+ while (check() > 0 && Date.now() < deadline) {
100
+ await new Promise((resolve) => setTimeout(resolve, 10));
101
+ await nextTick();
102
+ }
103
+ }
104
+
105
+ /** Let the message box mount (own app) and its promise chain settle. */
106
+ async function flush() {
107
+ await nextTick();
108
+ await nextTick();
109
+ await new Promise((resolve) => setTimeout(resolve, 0));
110
+ }
111
+
112
+ function confirmButton(): HTMLButtonElement {
113
+ const btn = document.body.querySelector<HTMLButtonElement>(".hk-message-box-confirm");
114
+ expect(btn, "message box confirm button renders").toBeTruthy();
115
+ return btn!;
116
+ }
117
+
118
+ /** Dismiss every open message box and wait out its host's self-unmount
119
+ * (leave transition + timer) so no zombie app re-renders into the next
120
+ * test's DOM. No-op when a test never opened one. */
121
+ async function teardownMessageBoxes() {
122
+ if (!document.body.querySelector(".hk-message-box-confirm")) return;
123
+ for (const btn of [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-confirm")]) {
124
+ btn.click();
125
+ }
126
+ const deadline = Date.now() + 2500;
127
+ // The modal root (not just the confirm button) must be gone: the
128
+ // leave transition + cleanup timer fully unmount the host app, so no
129
+ // zombie registration lingers in the shared popup stack.
130
+ while (
131
+ (document.body.querySelector(".hk-message-box-confirm") ||
132
+ document.body.querySelector(".hk-modal-root")) &&
133
+ Date.now() < deadline
134
+ ) {
135
+ await new Promise((resolve) => setTimeout(resolve, 20));
136
+ }
137
+ }
138
+
139
+ afterEach(async () => {
140
+ await teardownMessageBoxes();
141
+ for (const { app, container } of mounts.splice(0)) {
142
+ app.unmount();
143
+ container.remove();
144
+ }
145
+ document.body.innerHTML = "";
146
+ });
147
+
148
+ describe("HkAffixPicker", () => {
149
+ it("renders the default chip content with the selected option's label", () => {
150
+ const { container } = mountPicker({ selected: "jp" });
151
+ expect(queryChip(container).textContent).toContain("Japan");
152
+ });
153
+
154
+ it("single mode lists every option, marks the active one, closes on pick", async () => {
155
+ const { events, container } = mountPicker({ selected: "cn" });
156
+ await openPopup(container);
157
+ expect(rows()).toHaveLength(3);
158
+ expect(rows()[0].hasAttribute("data-active")).toBe(true);
159
+ rows()[1].click();
160
+ await nextTick();
161
+ expect(events.select.at(-1)).toBe("jp");
162
+ // Single mode closes after the pick (leave transition may linger).
163
+ await untilSettled(() => rows().length);
164
+ expect(rows()).toHaveLength(0);
165
+ });
166
+
167
+ it("filters rows by label, meta and keywords", async () => {
168
+ const { container } = mountPicker();
169
+ await openPopup(container);
170
+ await typeQuery("+81");
171
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("Japan")]);
172
+ await typeQuery("0086");
173
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("China")]);
174
+ await typeQuery("zzz");
175
+ expect(rows()).toHaveLength(0);
176
+ expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
177
+ });
178
+
179
+ it("multi mode renders selected tags and offers only the remaining rows", async () => {
180
+ const { container } = mountPicker({ mode: "multi", selected: ["cn", "jp"], activeKey: "cn" });
181
+ await openPopup(container);
182
+ expect(tags().map((t) => t.querySelector(".hk-affix-tag-text")?.textContent)).toEqual([
183
+ "China",
184
+ "Japan",
185
+ ]);
186
+ // The active key's tag carries the dot.
187
+ expect(tags()[0].querySelector(".hk-affix-tag-dot")).toBeTruthy();
188
+ expect(tags()[1].querySelector(".hk-affix-tag-dot")).toBeNull();
189
+ // Rows exclude the already-selected keys.
190
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("United States")]);
191
+ });
192
+
193
+ it("multi mode keeps the popup open when a row is picked", async () => {
194
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn"] });
195
+ await openPopup(container);
196
+ rows()[0].click();
197
+ await nextTick();
198
+ expect(events.select.at(-1)).toBe("jp");
199
+ expect(rows().length).toBeGreaterThan(0);
200
+ });
201
+
202
+ it("tag × opens a confirm dialog: Cancel keeps the tag, Confirm fires remove", async () => {
203
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
204
+ await openPopup(container);
205
+ // One tap never erases: the dialog names the entry instead.
206
+ tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
207
+ // The dialog mounts over several transition frames; wait for the
208
+ // settled state (box open, tag untouched) before asserting.
209
+ await untilBoxOpen('Remove "China"');
210
+ expect(events.remove).toHaveLength(0);
211
+ expect(tag("China")).toBeTruthy();
212
+ // Cancel keeps everything as it was.
213
+ [...document.body.querySelectorAll<HTMLButtonElement>(".hk-message-box-actions button")]
214
+ .find((b) => !b.classList.contains("hk-message-box-confirm"))!
215
+ .click();
216
+ await flush();
217
+ console.log("DBG-cancel openEvents", JSON.stringify(events.open), "tags", tags().length, "box", !!document.body.querySelector(".hk-message-box-text"));
218
+ expect(events.remove).toHaveLength(0);
219
+ expect(tag("China")).toBeTruthy();
220
+ // Second pass: the dialog's Confirm is what erases.
221
+ tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
222
+ await untilBoxOpen('Remove "China"');
223
+ confirmButton().click();
224
+ const deadline = Date.now() + 1500;
225
+ while (events.remove.length === 0 && Date.now() < deadline) {
226
+ await new Promise((resolve) => setTimeout(resolve, 10));
227
+ await nextTick();
228
+ }
229
+ expect(events.remove).toEqual(["cn"]);
230
+ expect(events.select).toHaveLength(0);
231
+ });
232
+
233
+ it("confirmRemove=false lets the × fire remove immediately, no dialog", async () => {
234
+ const { events, container } = mountPicker({
235
+ mode: "multi",
236
+ selected: ["cn"],
237
+ confirmRemove: false,
238
+ });
239
+ await openPopup(container);
240
+ tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
241
+ await flush();
242
+ expect(events.remove).toEqual(["cn"]);
243
+ expect(document.body.querySelector(".hk-message-box-text")).toBeNull();
244
+ });
245
+
246
+ /** Poll until the confirm dialog is mounted AND shows the given body
247
+ * text — the box rides HkModal's multi-frame open transition. */
248
+ async function untilBoxOpen(fragment: string): Promise<void> {
249
+ const deadline = Date.now() + 1500;
250
+ while (Date.now() < deadline) {
251
+ const text = document.body.querySelector(".hk-message-box-text")?.textContent ?? "";
252
+ if (text.includes(fragment)) {
253
+ // One extra frame so sibling re-renders (scrollbar lock, panel
254
+ // reposition) triggered by the modal settle too.
255
+ await new Promise((resolve) => setTimeout(resolve, 30));
256
+ await nextTick();
257
+ return;
258
+ }
259
+ await new Promise((resolve) => setTimeout(resolve, 10));
260
+ await nextTick();
261
+ }
262
+ expect(
263
+ document.body.querySelector(".hk-message-box-text")?.textContent ?? "",
264
+ `confirm dialog showing "${fragment}"`,
265
+ ).toContain(fragment);
266
+ }
267
+
268
+ it("tag body click emits select; close-on-select default keeps multi open", async () => {
269
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
270
+ await openPopup(container);
271
+ tags()[1].querySelector<HTMLButtonElement>(".hk-affix-tag-body")!.click();
272
+ await nextTick();
273
+ expect(events.select.at(-1)).toBe("jp");
274
+ expect(rows().length).toBeGreaterThan(0);
275
+ });
276
+
277
+ it("allowCustom offers a Use-row for unmatched queries and suppresses it on exact matches", async () => {
278
+ const { events, container } = mountPicker({ allowCustom: true });
279
+ await openPopup(container);
280
+ await typeQuery("gitee.com");
281
+ const custom = document.querySelector<HTMLButtonElement>('.hk-affix-row[data-custom="true"]');
282
+ expect(custom?.textContent).toContain('Use "gitee.com"');
283
+ custom!.click();
284
+ await nextTick();
285
+ expect(events.custom).toEqual(["gitee.com"]);
286
+ // Single mode closed on the custom pick — wait out the leave ghost,
287
+ // then reopen for the exact-match phase.
288
+ await untilSettled(() => rows().length);
289
+ await openPopup(container);
290
+ await typeQuery("Japan");
291
+ expect(document.querySelector('.hk-affix-row[data-custom="true"]')).toBeNull();
292
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("Japan")]);
293
+ });
294
+
295
+ it("Enter in the search picks the first row, or the custom row when nothing matches", async () => {
296
+ const { events, container } = mountPicker({ allowCustom: true });
297
+ await openPopup(container);
298
+ await typeQuery("uni");
299
+ searchInput().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
300
+ await nextTick();
301
+ expect(events.select.at(-1)).toBe("us");
302
+ // Single mode closed on the pick — reopen before the custom phase.
303
+ await untilSettled(() => rows().length);
304
+ await openPopup(container);
305
+ await typeQuery("gitee.com");
306
+ searchInput().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
307
+ await nextTick();
308
+ expect(events.custom.at(-1)).toBe("gitee.com");
309
+ });
310
+
311
+ it("disables the chip and ignores interaction while disabled", async () => {
312
+ const { container } = mountPicker({ disabled: true });
313
+ expect(queryChip(container).disabled).toBe(true);
314
+ await openPopup(container);
315
+ expect(rows()).toHaveLength(0);
316
+ });
317
+ });