@celestia-island/hikari 0.40.4 → 0.40.7

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.40.4",
3
+ "version": "0.40.7",
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",
package/src/DemoApp.tsx CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  HButton, HIconButton, HTooltip, HBadge, HTag, HIcon, HSpinner,
9
9
  HProgressBar, HProgressRing, HGaugeRing,
10
10
  HInput, HSearchInput, HNumberInput, HPasswordInput, HTextarea,
11
+ HFileField,
12
+ type PickedFile, type RemoteFsAdapter,
11
13
  HCheckbox, HSwitch, HRadio, HSelect, HSelectPanel,
12
14
  HLocalizedInput,
13
15
  HSkeleton, HSkeletonList, HAvatar, HKbd, HDivider,
@@ -98,6 +100,28 @@ export default defineComponent({
98
100
  name: "DemoApp",
99
101
  setup() {
100
102
  const form = ref({ text: "", search: "", number: 0, password: "", textarea: "" });
103
+ const localFiles = ref<PickedFile[]>([]);
104
+ const remoteFiles = ref<PickedFile[]>([]);
105
+ // In-memory remote FS so the remote picker demo browses something real.
106
+ const demoRemoteFs: RemoteFsAdapter = (() => {
107
+ const tree: Record<string, { name: string; kind: "file" | "dir"; size?: number }[]> = {
108
+ "/": [
109
+ { name: "data", kind: "dir" },
110
+ { name: "reports", kind: "dir" },
111
+ { name: "readme.md", kind: "file", size: 1200 },
112
+ ],
113
+ "/data": [
114
+ { name: "stations.csv", kind: "file", size: 20480 },
115
+ { name: "sensors.json", kind: "file", size: 860 },
116
+ ],
117
+ "/reports": [{ name: "2026-Q3.pdf", kind: "file", size: 409600 }],
118
+ };
119
+ return {
120
+ async list(path: string) {
121
+ return { path, entries: tree[path] ?? [] };
122
+ },
123
+ };
124
+ })();
101
125
  const anyForm = computed(() =>
102
126
  Object.values(form.value).filter((v) => v !== "" && v !== 0).join(", ") || null,
103
127
  );
@@ -267,6 +291,32 @@ export default defineComponent({
267
291
  {anyForm.value ? <p class="result">Form: {anyForm.value}</p> : null}
268
292
  </section>
269
293
 
294
+ <section>
295
+ <h2>HFileField / HFileBrowserDialog</h2>
296
+ <div class="form-grid">
297
+ <HFileField
298
+ modelValue={localFiles.value}
299
+ onUpdate:modelValue={(v: PickedFile[]) => (localFiles.value = v)}
300
+ label="Local file"
301
+ multiple
302
+ accept=".csv,.json,.txt"
303
+ />
304
+ <HFileField
305
+ modelValue={remoteFiles.value}
306
+ onUpdate:modelValue={(v: PickedFile[]) => (remoteFiles.value = v)}
307
+ mode="remote"
308
+ label="Remote file"
309
+ adapter={demoRemoteFs}
310
+ quickLinks={[{ label: "Root", path: "/" }, { label: "Data", path: "/data" }]}
311
+ />
312
+ </div>
313
+ {localFiles.value.length + remoteFiles.value.length > 0 ? (
314
+ <p class="result">
315
+ Picked: {[...localFiles.value, ...remoteFiles.value].map((f) => f.name).join(", ")}
316
+ </p>
317
+ ) : null}
318
+ </section>
319
+
270
320
  <section>
271
321
  <h2>HCheckbox / HSwitch / HRadio</h2>
272
322
  <div class="row">
@@ -5,6 +5,7 @@ import HkAffixPicker, { type HkAffixOption } from "./HkAffixPicker";
5
5
 
6
6
  const OPTIONS: HkAffixOption[] = [
7
7
  { key: "cn", label: "China", meta: "+86", flag: "🇨🇳", keywords: "zhongguo 中国 0086" },
8
+ { key: "cn-main", label: "中华人民共和国", meta: "+86", flag: "🇨🇳" },
8
9
  { key: "jp", label: "Japan", meta: "+81", flag: "🇯🇵" },
9
10
  { key: "us", label: "United States", meta: "+1", flag: "🇺🇸" },
10
11
  ];
@@ -154,9 +155,9 @@ describe("HkAffixPicker", () => {
154
155
  it("single mode lists every option, marks the active one, closes on pick", async () => {
155
156
  const { events, container } = mountPicker({ selected: "cn" });
156
157
  await openPopup(container);
157
- expect(rows()).toHaveLength(3);
158
+ expect(rows()).toHaveLength(4);
158
159
  expect(rows()[0].hasAttribute("data-active")).toBe(true);
159
- rows()[1].click();
160
+ rows()[2].click();
160
161
  await nextTick();
161
162
  expect(events.select.at(-1)).toBe("jp");
162
163
  // Single mode closes after the pick (leave transition may linger).
@@ -176,6 +177,30 @@ describe("HkAffixPicker", () => {
176
177
  expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
177
178
  });
178
179
 
180
+ it("fuzzy fallback finds a renamed CJK label by in-order subsequence", async () => {
181
+ const { container } = mountPicker();
182
+ await openPopup(container);
183
+ // 中国 is NOT a substring of 中华人民共和国 — only the in-order
184
+ // subsequence fallback (中 at 0, 国 at 6) can surface the cn-main
185
+ // row the substring pass misses.
186
+ await typeQuery("中国");
187
+ const labels = rows().map((r) => r.textContent);
188
+ // Both the China row (keyword substring match) and the renamed
189
+ // cn-main row (fuzzy subsequence) surface.
190
+ expect(labels).toContain("🇨🇳China+86");
191
+ expect(labels.some((l) => l.includes("中华人民共和国"))).toBe(true);
192
+ });
193
+
194
+ it("order-violating query does not fuzzy-match the CJK label", async () => {
195
+ const { container } = mountPicker();
196
+ await openPopup(container);
197
+ // 国民 is out of order in 中华人民共和国 (国 comes after 民) — the
198
+ // subsequence pass must NOT surface the row.
199
+ await typeQuery("国民");
200
+ expect(rows()).toHaveLength(0);
201
+ expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
202
+ });
203
+
179
204
  it("re-attaches the row list when the empty-state query is cleared", async () => {
180
205
  const { container } = mountPicker();
181
206
  await openPopup(container);
@@ -190,6 +215,7 @@ describe("HkAffixPicker", () => {
190
215
  expect(document.querySelector(".hk-affix-empty")).toBeNull();
191
216
  expect(rows().map((r) => r.textContent)).toEqual([
192
217
  expect.stringContaining("China"),
218
+ expect.stringContaining("中华人民共和国"),
193
219
  expect.stringContaining("Japan"),
194
220
  expect.stringContaining("United States"),
195
221
  ]);
@@ -197,7 +223,12 @@ describe("HkAffixPicker", () => {
197
223
  expect(document.querySelector(".hk-affix-list")).toBeTruthy();
198
224
  // Re-filtering after the remount keeps working on the live list.
199
225
  await typeQuery("+86");
200
- expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("China")]);
226
+ // Both +86 rows match by substring (the cn-main row via its meta)
227
+ // the original fixtured "China" row is still surfaced.
228
+ expect(rows().map((r) => r.textContent)).toEqual([
229
+ expect.stringContaining("China"),
230
+ expect.stringContaining("中华人民共和国"),
231
+ ]);
201
232
  });
202
233
 
203
234
  it("multi mode renders selected tags and offers only the remaining rows", async () => {
@@ -211,13 +242,17 @@ describe("HkAffixPicker", () => {
211
242
  expect(tags()[0].querySelector(".hk-affix-tag-dot")).toBeTruthy();
212
243
  expect(tags()[1].querySelector(".hk-affix-tag-dot")).toBeNull();
213
244
  // Rows exclude the already-selected keys.
214
- expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("United States")]);
245
+ expect(rows().map((r) => r.textContent)).toEqual([
246
+ expect.stringContaining("中华人民共和国"),
247
+ expect.stringContaining("United States"),
248
+ ]);
215
249
  });
216
250
 
217
251
  it("multi mode keeps the popup open when a row is picked", async () => {
218
252
  const { events, container } = mountPicker({ mode: "multi", selected: ["cn"] });
219
253
  await openPopup(container);
220
- rows()[0].click();
254
+ // With cn selected, the remaining rows are [cn-main, jp, us]; pick jp.
255
+ rows()[1].click();
221
256
  await nextTick();
222
257
  expect(events.select.at(-1)).toBe("jp");
223
258
  expect(rows().length).toBeGreaterThan(0);
@@ -38,6 +38,18 @@ export interface HkAffixOption {
38
38
  disabled?: boolean;
39
39
  }
40
40
 
41
+ /** True when every character of `query` appears in `text` in the same
42
+ * order — gaps allowed ("中国" finds 中华人民共和国). Callers lowercase
43
+ * both sides first, matching the substring pass. */
44
+ function isSubsequence(query: string, text: string): boolean {
45
+ let i = 0;
46
+ for (const ch of text) {
47
+ if (ch === query[i]) i++;
48
+ if (i === query.length) return true;
49
+ }
50
+ return false;
51
+ }
52
+
41
53
  /**
42
54
  * HkAffixPicker — the standardized "affix chip + searchable popup list"
43
55
  * control. A chip rides the prefix (left) or suffix (right) slot of a
@@ -190,7 +202,16 @@ export const HkAffixPicker = defineComponent({
190
202
  );
191
203
 
192
204
  /** Rows of the pick list. Multi hides already-selected keys (they
193
- * live in the tag list instead); single always shows everything. */
205
+ * live in the tag list instead); single always shows everything.
206
+ *
207
+ * Filtering is TWO-PASS over a single field at a time (never across
208
+ * concatenated fields, which produces noise):
209
+ * 1. an exact substring match for precision (label / meta / key /
210
+ * keywords, case-folded);
211
+ * 2. an in-order character subsequence fallback (gaps allowed) —
212
+ * so a renamed or CJK-composed label like "中华人民共和国" is
213
+ * still found by its short form "中国" (中 at 0, 国 at 6).
214
+ */
194
215
  const filteredRows = computed<readonly HkAffixOption[]>(() => {
195
216
  const base =
196
217
  props.mode === "multi"
@@ -202,7 +223,12 @@ export const HkAffixPicker = defineComponent({
202
223
  if (o.label.toLowerCase().includes(q)) return true;
203
224
  if (o.meta && o.meta.toLowerCase().includes(q)) return true;
204
225
  if (o.key.toLowerCase().includes(q)) return true;
205
- return !!o.keywords && o.keywords.toLowerCase().includes(q);
226
+ if (o.keywords && o.keywords.toLowerCase().includes(q)) return true;
227
+ // Fuzzy fallback — per field, in-order subsequence, gaps allowed.
228
+ if (isSubsequence(q, o.label.toLowerCase())) return true;
229
+ if (o.meta && isSubsequence(q, o.meta.toLowerCase())) return true;
230
+ if (isSubsequence(q, o.key.toLowerCase())) return true;
231
+ return !!o.keywords && isSubsequence(q, o.keywords.toLowerCase());
206
232
  });
207
233
  });
208
234