@celestia-island/hikari 0.40.29 → 0.40.31

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.29",
3
+ "version": "0.40.31",
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",
@@ -67,7 +67,9 @@
67
67
  max-width: 24rem;
68
68
  }
69
69
 
70
- /* One selected entry: [flag] label (meta) •active-dot ×]
70
+ /* One selected entry: [flag] value|label (meta) •active-dot ×]
71
+ * With `tagValues` the primary text is the key's mapped value (italic
72
+ * muted "Not set" while empty); without it, the autonym label.
71
73
  * The × opens the shared confirm message box — the dialog's Confirm is
72
74
  * what actually erases; the × alone never does. */
73
75
  .hk-affix-tag {
@@ -126,6 +128,13 @@
126
128
  overflow: hidden;
127
129
  text-overflow: ellipsis;
128
130
  white-space: nowrap;
131
+
132
+ /* Value-driven tags (HkLocalizedInput): the language is listed but
133
+ * holds no text yet — the italic muted "Not set" placeholder. */
134
+ &[data-unset] {
135
+ font-style: italic;
136
+ color: rgb(var(--color-muted));
137
+ }
129
138
  }
130
139
 
131
140
  .hk-affix-tag-meta {
@@ -167,12 +176,12 @@
167
176
  }
168
177
 
169
178
  /* ── option rows ────────────────────────────────────────────────────── */
170
- /* Positioned host for the overlay scrollbar tracks: wraps ONLY the
171
- * scrolling list viewport (the menu body also contains the header/
172
- * search band), carries the popup's width constraints so the geometry
173
- * is unchanged by the wrapper. */
179
+ /* Width container for the option list — carries the popup's width
180
+ * constraints so the list block keeps its designed measure inside the
181
+ * hosting window (HkSelectPanel popout / mobile sheet). It is NOT a
182
+ * scroll region: the window owns THE single scrollbar and scrolls the
183
+ * whole popup content (tags + search + rows) as one. */
174
184
  .hk-affix-scroll {
175
- position: relative;
176
185
  display: flex;
177
186
  flex-direction: column;
178
187
  min-width: 13rem;
@@ -189,20 +198,15 @@
189
198
  width: 100%;
190
199
  }
191
200
 
201
+ /* NO max-height / overflow here — ONE SCROLLBAR PER WINDOW (2026-09-08
202
+ * user report: the language sheet showed two nested scrollbars, this
203
+ * list's 17rem cap + the sheet's own). The list grows with its content
204
+ * and the hosting window scrolls it. */
192
205
  .hk-affix-list {
193
206
  display: flex;
194
207
  flex-direction: column;
195
208
  gap: 1px;
196
209
  padding: 4px 6px 8px;
197
- max-height: 17rem;
198
- overflow-y: auto;
199
- /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
200
- * always hidden, never styled. */
201
- scrollbar-width: none;
202
-
203
- &::-webkit-scrollbar {
204
- display: none;
205
- }
206
210
  }
207
211
 
208
212
  .hk-affix-row {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Source contract: ONE SCROLLBAR PER WINDOW (2026-09-08 user report —
3
+ * the localized input's language sheet showed two nested scrollbars:
4
+ * the HkSelectPanel sheet's own AND the affix picker's 17rem-capped row
5
+ * list scrolling inside it). Principle: every window has exactly ONE
6
+ * scrollbar serving the window's own content; a second scroll level
7
+ * belongs in a sub-window, never an inline embed. HkAffixPicker's popup
8
+ * therefore mounts no scroll region of its own — the window surface
9
+ * (desktop popout / mobile sheet) scrolls tags + search + rows as one.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const scss = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
18
+ const tsx = readFileSync(join(here, "HkAffixPicker.tsx"), "utf-8");
19
+
20
+ /** Extract ONE balanced `{...}` declaration block following the given
21
+ * selector — a naive `[^}]*` would stop at the first nested `}` and let
22
+ * properties appended after a future nested block evade the pin. */
23
+ function cssBlock(src: string, selector: string): string {
24
+ const at = src.indexOf(selector);
25
+ expect(at, `${selector} block exists`).toBeGreaterThanOrEqual(0);
26
+ const open = src.indexOf("{", at);
27
+ let depth = 0;
28
+ for (let i = open; i < src.length; i++) {
29
+ if (src[i] === "{") depth++;
30
+ else if (src[i] === "}") {
31
+ depth--;
32
+ if (depth === 0) return src.slice(open, i + 1);
33
+ }
34
+ }
35
+ return "";
36
+ }
37
+
38
+ function expectNoScroll(block: string, what: string): void {
39
+ expect(block, `${what} block exists`).toBeTruthy();
40
+ expect(block).not.toMatch(/max-height/);
41
+ expect(block).not.toMatch(/overflow/);
42
+ expect(block).not.toMatch(/scrollbar-width/);
43
+ }
44
+
45
+ describe("HkAffixPicker single-scrollbar-per-window contract", () => {
46
+ it("the row list carries no max-height and no overflow of its own", () => {
47
+ expectNoScroll(cssBlock(scss, ".hk-affix-list"), ".hk-affix-list");
48
+ });
49
+
50
+ it("the width container is neither a scroll region nor a track host", () => {
51
+ const block = cssBlock(scss, ".hk-affix-scroll");
52
+ expectNoScroll(block, ".hk-affix-scroll");
53
+ // The old overlay-rail host role is gone — nothing needs a
54
+ // positioning context anymore.
55
+ expect(block).not.toMatch(/position\s*:/);
56
+ // Defense in depth: the mobile-sheet descendant override (width/
57
+ // centering only) must never grow a scroll region either.
58
+ const sheetBlock = cssBlock(
59
+ scss.slice(scss.indexOf(".hk-select-sheet-panel .hk-affix-scroll")),
60
+ ".hk-affix-scroll",
61
+ );
62
+ expect(sheetBlock, "sheet descendant block exists").toBeTruthy();
63
+ expectNoScroll(sheetBlock, ".hk-select-sheet-panel .hk-affix-scroll");
64
+ });
65
+
66
+ it("the popup mounts no overlay scrollbar machinery", () => {
67
+ expect(tsx).not.toContain("attachOverlayScrollbars");
68
+ expect(tsx).not.toContain("OverlayScrollbarHandle");
69
+ });
70
+ });
@@ -29,6 +29,7 @@ interface MountOptions {
29
29
  closeOnSelect?: boolean;
30
30
  confirmRemove?: boolean;
31
31
  disabled?: boolean;
32
+ tagValues?: Record<string, string>;
32
33
  }
33
34
 
34
35
  function mountPicker(opts: MountOptions = {}) {
@@ -52,6 +53,7 @@ function mountPicker(opts: MountOptions = {}) {
52
53
  closeOnSelect: opts.closeOnSelect,
53
54
  confirmRemove: opts.confirmRemove,
54
55
  disabled: opts.disabled ?? false,
56
+ tagValues: opts.tagValues,
55
57
  onSelect: (key: string) => events.select.push(key),
56
58
  onRemove: (key: string) => events.remove.push(key),
57
59
  onCustom: (q: string) => events.custom.push(q),
@@ -213,12 +215,12 @@ describe("HkAffixPicker", () => {
213
215
  const { container } = mountPicker();
214
216
  await openPopup(container);
215
217
  // A no-match query swaps the default slot to the empty branch —
216
- // the scrolling list (and its overlay-scrollbar host) unmounts.
218
+ // the list block unmounts.
217
219
  await typeQuery("zzz-none");
218
220
  expect(document.querySelector(".hk-affix-empty")).toBeTruthy();
219
221
  expect(document.querySelector(".hk-affix-scroll")).toBeNull();
220
222
  // Clearing the query remounts a FRESH list — the row list must come
221
- // back and the overlay host must be remounted for the scrollbar.
223
+ // back inside its width container.
222
224
  await typeQuery("");
223
225
  expect(document.querySelector(".hk-affix-empty")).toBeNull();
224
226
  expect(rows().map((r) => r.textContent)).toEqual([
@@ -380,4 +382,66 @@ describe("HkAffixPicker", () => {
380
382
  await openPopup(container);
381
383
  expect(rows()).toHaveLength(0);
382
384
  });
385
+
386
+ it("tagValues swaps the tag primary text to each key's value", async () => {
387
+ const { container } = mountPicker({
388
+ mode: "multi",
389
+ selected: ["cn", "jp"],
390
+ tagValues: { cn: "China's stored text", jp: " " },
391
+ });
392
+ await openPopup(container);
393
+ const texts = tags().map(
394
+ (t) => t.querySelector(".hk-affix-tag-text")?.textContent,
395
+ );
396
+ // cn carries its stored value; jp (whitespace-only = unfilled) shows
397
+ // the italic unset placeholder.
398
+ expect(texts).toEqual(["China's stored text", "Not set"]);
399
+ expect(tags()[0].querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(false);
400
+ expect(tags()[1].querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
401
+ // The autonym survives as the tag's identity in the title (and the
402
+ // confirm dialog / aria naming, asserted in the dialog tests).
403
+ expect(
404
+ tags()[0].querySelector<HTMLButtonElement>(".hk-affix-tag-body")?.title,
405
+ ).toContain("China");
406
+ // The pick rows are untouched — they keep the autonym labels.
407
+ expect(rows().map((r) => r.textContent)).toEqual([
408
+ expect.stringContaining("中华人民共和国"),
409
+ expect.stringContaining("United States"),
410
+ ]);
411
+ });
412
+
413
+ it("a tagValues key missing from the map renders the unset placeholder", async () => {
414
+ const { container } = mountPicker({
415
+ mode: "multi",
416
+ selected: ["us"],
417
+ tagValues: {},
418
+ });
419
+ await openPopup(container);
420
+ const text = tags()[0].querySelector(".hk-affix-tag-text")!;
421
+ expect(text.textContent).toBe("Not set");
422
+ expect(text.hasAttribute("data-unset")).toBe(true);
423
+ });
424
+
425
+ it("without tagValues the tags keep the autonym labels (no unset state)", async () => {
426
+ const { container } = mountPicker({ mode: "multi", selected: ["cn"] });
427
+ await openPopup(container);
428
+ const text = tags()[0].querySelector(".hk-affix-tag-text")!;
429
+ expect(text.textContent).toBe("China");
430
+ expect(text.hasAttribute("data-unset")).toBe(false);
431
+ });
432
+
433
+ it("mounts NO inner scroll region — the window owns the one scrollbar", async () => {
434
+ const { container } = mountPicker({
435
+ mode: "multi",
436
+ selected: ["cn", "jp", "us"],
437
+ });
438
+ await openPopup(container);
439
+ expect(
440
+ document.querySelector<HTMLElement>(".hk-affix-list"),
441
+ "row list renders",
442
+ ).toBeTruthy();
443
+ // No overlay-scroll chrome of the list's own inside the popup — the
444
+ // CSS/text half of the contract is pinned by the contract test.
445
+ expect(document.querySelector(".hk-affix-list .hk-scrollbar-track")).toBeNull();
446
+ });
383
447
  });
@@ -1,8 +1,6 @@
1
1
  import {
2
2
  computed,
3
3
  defineComponent,
4
- nextTick,
5
- onBeforeUnmount,
6
4
  ref,
7
5
  watch,
8
6
  type PropType,
@@ -12,10 +10,6 @@ import {
12
10
  import { ChevronDown, Plus, Search, X } from "lucide-vue-next";
13
11
 
14
12
  import { useI18n } from "../i18n/context";
15
- import {
16
- attachOverlayScrollbars,
17
- type OverlayScrollbarHandle,
18
- } from "../composables/useOverlayScrollbar";
19
13
 
20
14
  import HkInput from "./HkInput";
21
15
  import HkListTransition from "./HkListTransition";
@@ -71,6 +65,23 @@ function isSubsequence(query: string, text: string): boolean {
71
65
  * without leaving the keyboard flow (Enter picks the first row, or
72
66
  * the custom row when nothing matches).
73
67
  *
68
+ * ONE SCROLLBAR PER WINDOW: the popup mounts NO scroll region of its
69
+ * own — the window surface it opens as (the desktop popout or the
70
+ * mobile bottom sheet, both provided by HkSelectPanel) owns THE single
71
+ * scrollbar and scrolls the whole popup content (tags + search + rows)
72
+ * as one. The row list deliberately carries no max-height/overflow; a
73
+ * nested second scrollbar inside the same window is a contract
74
+ * violation (2026-09-08 user report: the language sheet scrolled twice).
75
+ *
76
+ * With `tagValues`, the multi picker's TAG LIST switches to
77
+ * value-driven primary text: a selected key renders its mapped value
78
+ * (trimmed), and a key with no value renders `tagUnsetText` in an
79
+ * italic muted state (`data-unset`) — "the language is listed but
80
+ * nothing typed yet". The autonym label stays in the tag's title /
81
+ * aria naming and the confirm dialog, which identify the LANGUAGE, not
82
+ * the current text. Hosts that don't pass `tagValues` keep the
83
+ * autonym-label tags.
84
+ *
74
85
  * The picker owns ZERO field semantics: selection state lives with the
75
86
  * host (`selected` key(s) in, events out), and the chip visuals come
76
87
  * from the host through the scoped `chip` slot — the same component
@@ -103,6 +114,18 @@ export const HkAffixPicker = defineComponent({
103
114
  /** Gate tag deletion behind a confirm message box (multi mode).
104
115
  * Default true; pass false when the host runs its own guard. */
105
116
  confirmRemove: { type: Boolean, default: true },
117
+ /** Per-key value text replacing the tag list's primary label (multi
118
+ * mode). Provided = value-driven tags: a key mapping to a non-empty
119
+ * string renders that string; an empty/missing key renders
120
+ * `tagUnsetText` italic (`data-unset`). Left undefined = the
121
+ * autonym-label tags. Does not affect the pick rows. */
122
+ tagValues: {
123
+ type: Object as PropType<Record<string, string>>,
124
+ default: undefined,
125
+ },
126
+ /** Text shown (italic) for a tag whose `tagValues` entry is empty;
127
+ * defaulted from the i18n bundle. */
128
+ tagUnsetText: { type: String, default: undefined },
106
129
  /** Override the default close-on-pick (single: true, multi: false).
107
130
  * E.g. a multi picker that should close after each add passes
108
131
  * true; a single picker that should stay open passes false. */
@@ -141,58 +164,17 @@ export const HkAffixPicker = defineComponent({
141
164
  * are outside THIS popup, and the panel's outside-close must not
142
165
  * tear the tag list down mid-decision. */
143
166
  const confirmHeld = ref(false);
144
- /** The scrolling option list and its overlay-scrollbar host (see the
145
- * default slot — the host wraps ONLY the list viewport, not the
146
- * header/search band). */
147
- const listRef = ref<HTMLElement | null>(null);
148
- const scrollHostRef = ref<HTMLElement | null>(null);
149
- /** Live overlay-scrollbar handle for the open popup; null when the
150
- * popup is closed (content not mounted). */
151
- let scrollbar: OverlayScrollbarHandle | null = null;
152
- /** The viewport element `scrollbar` is currently attached to, so a
153
- * remounted list (the empty-state swap) can be told apart from the
154
- * same in-flight list across content-size updates. */
155
- let scrollbarViewport: HTMLElement | null = null;
156
167
 
157
- function detachScrollbar() {
158
- scrollbar?.detach();
159
- scrollbar = null;
160
- scrollbarViewport = null;
161
- }
162
-
163
- function attachScrollbar() {
164
- detachScrollbar();
165
- if (listRef.value && scrollHostRef.value) {
166
- scrollbarViewport = listRef.value;
167
- scrollbar = attachOverlayScrollbars(listRef.value, {
168
- axis: "vertical",
169
- host: scrollHostRef.value,
170
- });
171
- }
172
- }
173
-
174
- // A fresh open starts calm: empty filter.
168
+ // A fresh open starts calm: empty filter. (The popup mounts no
169
+ // scroll machinery of its own — the window surface owns the one
170
+ // scrollbar — so there is nothing to attach/detach on open/close.)
175
171
  watch(open, (v) => {
176
172
  if (!v) {
177
173
  query.value = "";
178
174
  }
179
- if (v) {
180
- // The list mounts on this very render — attach the overlay
181
- // scrollbar once the DOM has landed. A same-tick open→close
182
- // must not arm it on the leaving popup (the close branch
183
- // already detached it).
184
- void nextTick(() => {
185
- if (!open.value) return;
186
- attachScrollbar();
187
- });
188
- } else {
189
- detachScrollbar();
190
- }
191
175
  emit("update:open", v);
192
176
  });
193
177
 
194
- onBeforeUnmount(detachScrollbar);
195
-
196
178
  const selectedKeys = computed<readonly string[]>(() =>
197
179
  Array.isArray(props.selected) ? props.selected : props.selected ? [props.selected] : [],
198
180
  );
@@ -232,29 +214,6 @@ export const HkAffixPicker = defineComponent({
232
214
  });
233
215
  });
234
216
 
235
- // Content-size changes from the search filter change the thumb
236
- // geometry without resizing the viewport — keep it in sync on the
237
- // live scrollbar (no-op while the popup is closed). Post-flush so
238
- // the DOM (esp. a remounted list after the empty-state swap) has
239
- // landed and the template refs point at the live nodes before we
240
- // decide whether to attach, re-attach or update.
241
- watch(
242
- [filteredRows, query],
243
- () => {
244
- if (!open.value) return;
245
- if (!listRef.value) {
246
- detachScrollbar();
247
- return;
248
- }
249
- if (listRef.value !== scrollbarViewport) {
250
- attachScrollbar();
251
- return;
252
- }
253
- scrollbar?.update();
254
- },
255
- { flush: "post" },
256
- );
257
-
258
217
  /** Exact label match suppresses the custom row while the user is
259
218
  * simply re-typing an existing entry. */
260
219
  const exactMatch = computed(
@@ -351,6 +310,14 @@ export const HkAffixPicker = defineComponent({
351
310
  props.searchPlaceholder ?? t("hikari::affixPicker.search", "Search");
352
311
  const emptyText = props.emptyText ?? t("hikari::affixPicker.empty", "No matches");
353
312
  const removeLabel = t("hikari::affixPicker.remove", "Remove");
313
+ // Value-driven tags (tagValues provided): each tag's primary text
314
+ // is its mapped value, or the italic unset text when empty. The
315
+ // autonym stays the tag's identity in title/aria and the dialog.
316
+ const valueDriven = props.tagValues !== undefined;
317
+ const unsetText =
318
+ props.tagUnsetText ?? t("hikari::affixPicker.unset", "Not set");
319
+ const tagValue = (key: string): string =>
320
+ (props.tagValues?.[key] ?? "").trim();
354
321
  const placement = props.side === "suffix" ? "bottom-end" : "bottom-start";
355
322
  return (
356
323
  <>
@@ -420,6 +387,7 @@ export const HkAffixPicker = defineComponent({
420
387
  class="hk-affix-tag-list"
421
388
  >
422
389
  {tags.map((tag) => {
390
+ const value = tagValue(tag.key);
423
391
  return (
424
392
  <div
425
393
  key={tag.key}
@@ -429,7 +397,9 @@ export const HkAffixPicker = defineComponent({
429
397
  <button
430
398
  type="button"
431
399
  class="hk-affix-tag-body"
432
- title={`${tag.label}${tag.meta ? ` (${tag.meta})` : ""}`}
400
+ title={`${tag.label}${tag.meta ? ` (${tag.meta})` : ""}${
401
+ valueDriven ? `: ${value || unsetText}` : ""
402
+ }`}
433
403
  aria-label={`${t("hikari::affixPicker.switchTo", "Switch to")} ${tag.label}`}
434
404
  onClick={(e: MouseEvent) => {
435
405
  e.stopPropagation();
@@ -441,7 +411,12 @@ export const HkAffixPicker = defineComponent({
441
411
  {tag.flag}
442
412
  </span>
443
413
  )}
444
- <span class="hk-affix-tag-text">{tag.label}</span>
414
+ <span
415
+ class="hk-affix-tag-text"
416
+ data-unset={valueDriven && !value ? "" : undefined}
417
+ >
418
+ {valueDriven ? value || unsetText : tag.label}
419
+ </span>
445
420
  {tag.meta && (
446
421
  <span class="hk-affix-tag-meta">{tag.meta}</span>
447
422
  )}
@@ -499,8 +474,12 @@ export const HkAffixPicker = defineComponent({
499
474
  ),
500
475
  default: () =>
501
476
  rows.length > 0 || customVisible.value ? (
502
- <div class="hk-affix-scroll" ref={scrollHostRef}>
503
- <div class="hk-affix-list" ref={listRef}>
477
+ /* Width container only — the window surface (HkSelectPanel
478
+ * popout / sheet) owns THE single scrollbar and scrolls this
479
+ * list with the rest of the popup content; no inner
480
+ * max-height/overflow here, ever. */
481
+ <div class="hk-affix-scroll">
482
+ <div class="hk-affix-list">
504
483
  {rows.map((option) => {
505
484
  const active =
506
485
  props.mode === "single"
@@ -30,8 +30,9 @@ function formatError(err: CapturedError): string {
30
30
  * Captures descendant errors via `onErrorCaptured` and stops propagation.
31
31
  * The built-in fallback is the same HkErrorLanding card the family's
32
32
  * full-page takeovers use (inline variant): tone icon, headline, the error
33
- * name as the code chip, the message as the description, the raw
34
- * name/message/stack in a collapsible JSON tree, plus retry / copy actions.
33
+ * name as the tone-matched HkBadge chip, the message as the description,
34
+ * the raw name/message/stack in the fixed-height JSON tree pane, plus
35
+ * retry / copy actions.
35
36
  */
36
37
  export default defineComponent({
37
38
  name: "HkErrorBoundary",