@celestia-island/hikari 0.32.4 → 0.33.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.33.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,277 @@
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 × arms on the first activation (danger tint) and erases on the
72
+ * second; desktop hover previews the × so the row reads as deletable. */
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
+ &[data-armed] {
98
+ border-color: rgb(var(--color-danger, 240 70 80) / 55%);
99
+ background: rgb(var(--color-danger, 240 70 80) / 12%);
100
+ }
101
+ }
102
+
103
+ .hk-affix-tag-body {
104
+ flex: 1;
105
+ min-width: 0;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ gap: 6px;
109
+ padding: 0;
110
+ border: 0;
111
+ background: transparent;
112
+ color: rgb(var(--color-text));
113
+ font-size: var(--text-sm);
114
+ line-height: 1.4;
115
+ text-align: left;
116
+ cursor: pointer;
117
+ appearance: none;
118
+ }
119
+
120
+ .hk-affix-tag-flag {
121
+ flex-shrink: 0;
122
+ font-size: var(--text-sm);
123
+ line-height: 1;
124
+ font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
125
+ sans-serif;
126
+ }
127
+
128
+ .hk-affix-tag-text {
129
+ min-width: 0;
130
+ overflow: hidden;
131
+ text-overflow: ellipsis;
132
+ white-space: nowrap;
133
+ }
134
+
135
+ .hk-affix-tag-meta {
136
+ flex-shrink: 0;
137
+ color: rgb(var(--color-muted));
138
+ font-size: calc(var(--text-xs) * 0.92);
139
+ font-variant-numeric: tabular-nums;
140
+ }
141
+
142
+ .hk-affix-tag-dot {
143
+ flex-shrink: 0;
144
+ width: 5px;
145
+ height: 5px;
146
+ border-radius: 50%;
147
+ background: rgb(var(--color-primary));
148
+ }
149
+
150
+ .hk-affix-tag-x {
151
+ flex: none;
152
+ display: inline-flex;
153
+ align-items: center;
154
+ justify-content: center;
155
+ width: 1.5rem;
156
+ height: 1.5rem;
157
+ padding: 0;
158
+ border: 0;
159
+ border-radius: 999px;
160
+ background: transparent;
161
+ color: rgb(var(--color-muted));
162
+ cursor: pointer;
163
+ appearance: none;
164
+ transition: color 0.12s ease, background 0.12s ease;
165
+
166
+ &:hover,
167
+ &:focus-visible {
168
+ color: rgb(var(--color-danger, 240 70 80));
169
+ background: rgb(var(--color-danger, 240 70 80) / 12%);
170
+ }
171
+
172
+ [data-armed] & {
173
+ color: rgb(var(--color-danger, 240 70 80));
174
+ background: rgb(var(--color-danger, 240 70 80) / 18%);
175
+ }
176
+ }
177
+
178
+ /* ── option rows ────────────────────────────────────────────────────── */
179
+ .hk-affix-list {
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 1px;
183
+ padding: 4px 6px 8px;
184
+ min-width: 13rem;
185
+ max-width: min(19rem, calc(100vw - 2rem));
186
+ max-height: 17rem;
187
+ overflow-y: auto;
188
+ }
189
+
190
+ .hk-affix-row {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 8px;
194
+ width: 100%;
195
+ min-height: 2.25rem;
196
+ padding: 4px 8px;
197
+ border: none;
198
+ border-radius: var(--radius-sm, 6px);
199
+ background: transparent;
200
+ color: rgb(var(--color-text));
201
+ font-size: var(--text-sm, 14px);
202
+ text-align: left;
203
+ cursor: pointer;
204
+ appearance: none;
205
+ transition: background 0.1s ease;
206
+
207
+ &:hover,
208
+ &:focus-visible {
209
+ background: rgb(var(--color-primary) / 10%);
210
+ outline: none;
211
+ }
212
+
213
+ &[data-active] {
214
+ background: rgb(var(--color-primary) / 16%);
215
+ font-weight: 600;
216
+ }
217
+
218
+ &[data-disabled] {
219
+ opacity: 0.5;
220
+ cursor: default;
221
+
222
+ &:hover {
223
+ background: transparent;
224
+ }
225
+ }
226
+
227
+ /* The "Use <query>" free-text row reads as an add affordance. */
228
+ &[data-custom="true"] {
229
+ color: rgb(var(--color-primary));
230
+
231
+ .hk-affix-row-label {
232
+ font-weight: 600;
233
+ overflow: hidden;
234
+ text-overflow: ellipsis;
235
+ white-space: nowrap;
236
+ }
237
+ }
238
+ }
239
+
240
+ .hk-affix-row-flag {
241
+ flex: none;
242
+ font-size: calc(var(--text-base, 15px) * 1.05);
243
+ line-height: 1;
244
+ font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
245
+ sans-serif;
246
+ }
247
+
248
+ .hk-affix-row-label {
249
+ flex: 1;
250
+ min-width: 0;
251
+ overflow: hidden;
252
+ text-overflow: ellipsis;
253
+ white-space: nowrap;
254
+ }
255
+
256
+ .hk-affix-row-meta {
257
+ flex: none;
258
+ color: rgb(var(--color-muted));
259
+ font-size: calc(var(--text-sm, 14px) * 0.9);
260
+ font-variant-numeric: tabular-nums;
261
+ }
262
+
263
+ .hk-affix-empty {
264
+ padding: 14px 12px;
265
+ color: rgb(var(--color-muted));
266
+ font-size: var(--text-sm, 14px);
267
+ text-align: center;
268
+ }
269
+
270
+ @media (prefers-reduced-motion: reduce) {
271
+ .hk-affix-chip,
272
+ .hk-affix-row,
273
+ .hk-affix-tag,
274
+ .hk-affix-tag-x {
275
+ transition: none;
276
+ }
277
+ }
@@ -0,0 +1,237 @@
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
+ disabled?: boolean;
22
+ }
23
+
24
+ function mountPicker(opts: MountOptions = {}) {
25
+ const events = {
26
+ select: [] as string[],
27
+ remove: [] as string[],
28
+ custom: [] as string[],
29
+ open: [] as boolean[],
30
+ };
31
+ const container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ const app = createApp({
34
+ render: () =>
35
+ h(HkAffixPicker, {
36
+ options: OPTIONS,
37
+ mode: opts.mode ?? "single",
38
+ selected: opts.selected ?? "",
39
+ activeKey: opts.activeKey,
40
+ allowCustom: opts.allowCustom ?? false,
41
+ searchable: opts.searchable ?? true,
42
+ closeOnSelect: opts.closeOnSelect,
43
+ disabled: opts.disabled ?? false,
44
+ onSelect: (key: string) => events.select.push(key),
45
+ onRemove: (key: string) => events.remove.push(key),
46
+ onCustom: (q: string) => events.custom.push(q),
47
+ "onUpdate:open": (v: boolean) => events.open.push(v),
48
+ }),
49
+ });
50
+ app.mount(container);
51
+ mounts.push({ app, container });
52
+ return { events, container };
53
+ }
54
+
55
+ function queryChip(container: HTMLElement): HTMLButtonElement {
56
+ const chip = container.querySelector<HTMLButtonElement>(".hk-affix-chip");
57
+ expect(chip, "affix chip renders").toBeTruthy();
58
+ return chip!;
59
+ }
60
+
61
+ async function openPopup(container: HTMLElement) {
62
+ queryChip(container).click();
63
+ await nextTick();
64
+ await nextTick();
65
+ }
66
+
67
+ function rows(): HTMLButtonElement[] {
68
+ return [...document.querySelectorAll<HTMLButtonElement>(".hk-affix-row")];
69
+ }
70
+
71
+ function tags(): HTMLElement[] {
72
+ return [...document.querySelectorAll<HTMLElement>(".hk-affix-tag")];
73
+ }
74
+
75
+ function tag(label: string): HTMLElement | undefined {
76
+ return tags().find(
77
+ (t) => (t.querySelector(".hk-affix-tag-text")?.textContent ?? "") === label,
78
+ );
79
+ }
80
+
81
+ function searchInput(): HTMLInputElement {
82
+ const input = document.querySelector<HTMLInputElement>(".hk-affix-search input");
83
+ expect(input, "search field renders").toBeTruthy();
84
+ return input!;
85
+ }
86
+
87
+ async function typeQuery(value: string) {
88
+ const input = searchInput();
89
+ input.value = value;
90
+ input.dispatchEvent(new Event("input", { bubbles: true }));
91
+ await nextTick();
92
+ await nextTick();
93
+ }
94
+
95
+ async function untilSettled(check: () => number): Promise<void> {
96
+ const deadline = Date.now() + 800;
97
+ while (check() > 0 && Date.now() < deadline) {
98
+ await new Promise((resolve) => setTimeout(resolve, 10));
99
+ await nextTick();
100
+ }
101
+ }
102
+
103
+ afterEach(() => {
104
+ for (const { app, container } of mounts.splice(0)) {
105
+ app.unmount();
106
+ container.remove();
107
+ }
108
+ document.body.innerHTML = "";
109
+ });
110
+
111
+ describe("HkAffixPicker", () => {
112
+ it("renders the default chip content with the selected option's label", () => {
113
+ const { container } = mountPicker({ selected: "jp" });
114
+ expect(queryChip(container).textContent).toContain("Japan");
115
+ });
116
+
117
+ it("single mode lists every option, marks the active one, closes on pick", async () => {
118
+ const { events, container } = mountPicker({ selected: "cn" });
119
+ await openPopup(container);
120
+ expect(rows()).toHaveLength(3);
121
+ expect(rows()[0].hasAttribute("data-active")).toBe(true);
122
+ rows()[1].click();
123
+ await nextTick();
124
+ expect(events.select.at(-1)).toBe("jp");
125
+ // Single mode closes after the pick (leave transition may linger).
126
+ await untilSettled(() => rows().length);
127
+ expect(rows()).toHaveLength(0);
128
+ });
129
+
130
+ it("filters rows by label, meta and keywords", async () => {
131
+ const { container } = mountPicker();
132
+ await openPopup(container);
133
+ await typeQuery("+81");
134
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("Japan")]);
135
+ await typeQuery("0086");
136
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("China")]);
137
+ await typeQuery("zzz");
138
+ expect(rows()).toHaveLength(0);
139
+ expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
140
+ });
141
+
142
+ it("multi mode renders selected tags and offers only the remaining rows", async () => {
143
+ const { container } = mountPicker({ mode: "multi", selected: ["cn", "jp"], activeKey: "cn" });
144
+ await openPopup(container);
145
+ expect(tags().map((t) => t.querySelector(".hk-affix-tag-text")?.textContent)).toEqual([
146
+ "China",
147
+ "Japan",
148
+ ]);
149
+ // The active key's tag carries the dot.
150
+ expect(tags()[0].querySelector(".hk-affix-tag-dot")).toBeTruthy();
151
+ expect(tags()[1].querySelector(".hk-affix-tag-dot")).toBeNull();
152
+ // Rows exclude the already-selected keys.
153
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("United States")]);
154
+ });
155
+
156
+ it("multi mode keeps the popup open when a row is picked", async () => {
157
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn"] });
158
+ await openPopup(container);
159
+ rows()[0].click();
160
+ await nextTick();
161
+ expect(events.select.at(-1)).toBe("jp");
162
+ expect(rows().length).toBeGreaterThan(0);
163
+ });
164
+
165
+ it("two-step tag delete: × arms, × again confirms; body click disarms", async () => {
166
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
167
+ await openPopup(container);
168
+ const x = tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-x")!;
169
+ x.click();
170
+ await nextTick();
171
+ expect(tags()[0].hasAttribute("data-armed")).toBe(true);
172
+ expect(events.remove).toHaveLength(0);
173
+ // Second activation confirms — the tag leaves the list.
174
+ x.click();
175
+ await nextTick();
176
+ expect(events.remove).toEqual(["cn"]);
177
+ // Body click on an ARMED tag disarms it instead of picking.
178
+ const jpBody = tag("Japan")!.querySelector<HTMLButtonElement>(".hk-affix-tag-body")!;
179
+ tag("Japan")!.querySelector<HTMLButtonElement>(".hk-affix-tag-x")!.click();
180
+ await nextTick();
181
+ expect(tag("Japan")?.hasAttribute("data-armed")).toBe(true);
182
+ jpBody.click();
183
+ await nextTick();
184
+ expect(tag("Japan")?.hasAttribute("data-armed")).toBe(false);
185
+ expect(events.select).toHaveLength(0);
186
+ });
187
+
188
+ it("tag body click emits select; close-on-select default keeps multi open", async () => {
189
+ const { events, container } = mountPicker({ mode: "multi", selected: ["cn", "jp"] });
190
+ await openPopup(container);
191
+ tags()[1].querySelector<HTMLButtonElement>(".hk-affix-tag-body")!.click();
192
+ await nextTick();
193
+ expect(events.select.at(-1)).toBe("jp");
194
+ expect(rows().length).toBeGreaterThan(0);
195
+ });
196
+
197
+ it("allowCustom offers a Use-row for unmatched queries and suppresses it on exact matches", async () => {
198
+ const { events, container } = mountPicker({ allowCustom: true });
199
+ await openPopup(container);
200
+ await typeQuery("gitee.com");
201
+ const custom = document.querySelector<HTMLButtonElement>('.hk-affix-row[data-custom="true"]');
202
+ expect(custom?.textContent).toContain('Use "gitee.com"');
203
+ custom!.click();
204
+ await nextTick();
205
+ expect(events.custom).toEqual(["gitee.com"]);
206
+ // Single mode closed on the custom pick — wait out the leave ghost,
207
+ // then reopen for the exact-match phase.
208
+ await untilSettled(() => rows().length);
209
+ await openPopup(container);
210
+ await typeQuery("Japan");
211
+ expect(document.querySelector('.hk-affix-row[data-custom="true"]')).toBeNull();
212
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("Japan")]);
213
+ });
214
+
215
+ it("Enter in the search picks the first row, or the custom row when nothing matches", async () => {
216
+ const { events, container } = mountPicker({ allowCustom: true });
217
+ await openPopup(container);
218
+ await typeQuery("uni");
219
+ searchInput().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
220
+ await nextTick();
221
+ expect(events.select.at(-1)).toBe("us");
222
+ // Single mode closed on the pick — reopen before the custom phase.
223
+ await untilSettled(() => rows().length);
224
+ await openPopup(container);
225
+ await typeQuery("gitee.com");
226
+ searchInput().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
227
+ await nextTick();
228
+ expect(events.custom.at(-1)).toBe("gitee.com");
229
+ });
230
+
231
+ it("disables the chip and ignores interaction while disabled", async () => {
232
+ const { container } = mountPicker({ disabled: true });
233
+ expect(queryChip(container).disabled).toBe(true);
234
+ await openPopup(container);
235
+ expect(rows()).toHaveLength(0);
236
+ });
237
+ });