@celestia-island/hikari 0.40.1 → 0.40.2

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.1",
3
+ "version": "0.40.2",
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",
@@ -167,15 +167,32 @@
167
167
  }
168
168
 
169
169
  /* ── 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. */
174
+ .hk-affix-scroll {
175
+ position: relative;
176
+ display: flex;
177
+ flex-direction: column;
178
+ min-width: 13rem;
179
+ max-width: min(19rem, calc(100vw - 2rem));
180
+ }
181
+
170
182
  .hk-affix-list {
171
183
  display: flex;
172
184
  flex-direction: column;
173
185
  gap: 1px;
174
186
  padding: 4px 6px 8px;
175
- min-width: 13rem;
176
- max-width: min(19rem, calc(100vw - 2rem));
177
187
  max-height: 17rem;
178
188
  overflow-y: auto;
189
+ /* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
190
+ * always hidden, never styled. */
191
+ scrollbar-width: none;
192
+
193
+ &::-webkit-scrollbar {
194
+ display: none;
195
+ }
179
196
  }
180
197
 
181
198
  .hk-affix-row {
@@ -176,6 +176,30 @@ describe("HkAffixPicker", () => {
176
176
  expect(document.querySelector(".hk-affix-empty")?.textContent).toContain("No matches");
177
177
  });
178
178
 
179
+ it("re-attaches the row list when the empty-state query is cleared", async () => {
180
+ const { container } = mountPicker();
181
+ await openPopup(container);
182
+ // A no-match query swaps the default slot to the empty branch —
183
+ // the scrolling list (and its overlay-scrollbar host) unmounts.
184
+ await typeQuery("zzz-none");
185
+ expect(document.querySelector(".hk-affix-empty")).toBeTruthy();
186
+ expect(document.querySelector(".hk-affix-scroll")).toBeNull();
187
+ // Clearing the query remounts a FRESH list — the row list must come
188
+ // back and the overlay host must be remounted for the scrollbar.
189
+ await typeQuery("");
190
+ expect(document.querySelector(".hk-affix-empty")).toBeNull();
191
+ expect(rows().map((r) => r.textContent)).toEqual([
192
+ expect.stringContaining("China"),
193
+ expect.stringContaining("Japan"),
194
+ expect.stringContaining("United States"),
195
+ ]);
196
+ expect(document.querySelector(".hk-affix-scroll")).toBeTruthy();
197
+ expect(document.querySelector(".hk-affix-list")).toBeTruthy();
198
+ // Re-filtering after the remount keeps working on the live list.
199
+ await typeQuery("+86");
200
+ expect(rows().map((r) => r.textContent)).toEqual([expect.stringContaining("China")]);
201
+ });
202
+
179
203
  it("multi mode renders selected tags and offers only the remaining rows", async () => {
180
204
  const { container } = mountPicker({ mode: "multi", selected: ["cn", "jp"], activeKey: "cn" });
181
205
  await openPopup(container);
@@ -1,8 +1,21 @@
1
- import { computed, defineComponent, ref, watch, type PropType, type SlotsType } from "vue";
1
+ import {
2
+ computed,
3
+ defineComponent,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ ref,
7
+ watch,
8
+ type PropType,
9
+ type SlotsType,
10
+ } from "vue";
2
11
 
3
12
  import { ChevronDown, Plus, Search, X } from "lucide-vue-next";
4
13
 
5
14
  import { useI18n } from "../i18n/context";
15
+ import {
16
+ attachOverlayScrollbars,
17
+ type OverlayScrollbarHandle,
18
+ } from "../composables/useOverlayScrollbar";
6
19
 
7
20
  import HkInput from "./HkInput";
8
21
  import HkListTransition from "./HkListTransition";
@@ -116,15 +129,58 @@ export const HkAffixPicker = defineComponent({
116
129
  * are outside THIS popup, and the panel's outside-close must not
117
130
  * tear the tag list down mid-decision. */
118
131
  const confirmHeld = ref(false);
132
+ /** The scrolling option list and its overlay-scrollbar host (see the
133
+ * default slot — the host wraps ONLY the list viewport, not the
134
+ * header/search band). */
135
+ const listRef = ref<HTMLElement | null>(null);
136
+ const scrollHostRef = ref<HTMLElement | null>(null);
137
+ /** Live overlay-scrollbar handle for the open popup; null when the
138
+ * popup is closed (content not mounted). */
139
+ let scrollbar: OverlayScrollbarHandle | null = null;
140
+ /** The viewport element `scrollbar` is currently attached to, so a
141
+ * remounted list (the empty-state swap) can be told apart from the
142
+ * same in-flight list across content-size updates. */
143
+ let scrollbarViewport: HTMLElement | null = null;
144
+
145
+ function detachScrollbar() {
146
+ scrollbar?.detach();
147
+ scrollbar = null;
148
+ scrollbarViewport = null;
149
+ }
150
+
151
+ function attachScrollbar() {
152
+ detachScrollbar();
153
+ if (listRef.value && scrollHostRef.value) {
154
+ scrollbarViewport = listRef.value;
155
+ scrollbar = attachOverlayScrollbars(listRef.value, {
156
+ axis: "vertical",
157
+ host: scrollHostRef.value,
158
+ });
159
+ }
160
+ }
119
161
 
120
162
  // A fresh open starts calm: empty filter.
121
163
  watch(open, (v) => {
122
164
  if (!v) {
123
165
  query.value = "";
124
166
  }
167
+ if (v) {
168
+ // The list mounts on this very render — attach the overlay
169
+ // scrollbar once the DOM has landed. A same-tick open→close
170
+ // must not arm it on the leaving popup (the close branch
171
+ // already detached it).
172
+ void nextTick(() => {
173
+ if (!open.value) return;
174
+ attachScrollbar();
175
+ });
176
+ } else {
177
+ detachScrollbar();
178
+ }
125
179
  emit("update:open", v);
126
180
  });
127
181
 
182
+ onBeforeUnmount(detachScrollbar);
183
+
128
184
  const selectedKeys = computed<readonly string[]>(() =>
129
185
  Array.isArray(props.selected) ? props.selected : props.selected ? [props.selected] : [],
130
186
  );
@@ -150,6 +206,29 @@ export const HkAffixPicker = defineComponent({
150
206
  });
151
207
  });
152
208
 
209
+ // Content-size changes from the search filter change the thumb
210
+ // geometry without resizing the viewport — keep it in sync on the
211
+ // live scrollbar (no-op while the popup is closed). Post-flush so
212
+ // the DOM (esp. a remounted list after the empty-state swap) has
213
+ // landed and the template refs point at the live nodes before we
214
+ // decide whether to attach, re-attach or update.
215
+ watch(
216
+ [filteredRows, query],
217
+ () => {
218
+ if (!open.value) return;
219
+ if (!listRef.value) {
220
+ detachScrollbar();
221
+ return;
222
+ }
223
+ if (listRef.value !== scrollbarViewport) {
224
+ attachScrollbar();
225
+ return;
226
+ }
227
+ scrollbar?.update();
228
+ },
229
+ { flush: "post" },
230
+ );
231
+
153
232
  /** Exact label match suppresses the custom row while the user is
154
233
  * simply re-typing an existing entry. */
155
234
  const exactMatch = computed(
@@ -394,8 +473,9 @@ export const HkAffixPicker = defineComponent({
394
473
  ),
395
474
  default: () =>
396
475
  rows.length > 0 || customVisible.value ? (
397
- <div class="hk-affix-list">
398
- {rows.map((option) => {
476
+ <div class="hk-affix-scroll" ref={scrollHostRef}>
477
+ <div class="hk-affix-list" ref={listRef}>
478
+ {rows.map((option) => {
399
479
  const active =
400
480
  props.mode === "single"
401
481
  ? selectedKeys.value.includes(option.key)
@@ -443,6 +523,7 @@ export const HkAffixPicker = defineComponent({
443
523
  </span>
444
524
  </button>
445
525
  )}
526
+ </div>
446
527
  </div>
447
528
  ) : (
448
529
  <div class="hk-affix-empty">{emptyText}</div>
@@ -4,25 +4,15 @@
4
4
  width: 100%;
5
5
  }
6
6
 
7
- /* The dial-code chip riding the field's LEFT edge — flag glyph + "+86"
8
- * + caret, layered on the shared .hk-affix-chip base (hover, disabled,
9
- * focus handling live there). A button so it is focusable and
10
- * AT-reachable; mousedown is suppressed in the shared picker so
11
- * clicking it never steals focus from the number field. */
7
+ /* The dial-code chip riding the field's LEFT edge — dial code + caret,
8
+ * layered on the shared .hk-affix-chip base (hover, disabled, focus
9
+ * handling live there). A button so it is focusable and AT-reachable;
10
+ * mousedown is suppressed in the shared picker so clicking it never
11
+ * steals focus from the number field. */
12
12
  .hk-phone-chip {
13
13
  max-width: 11rem;
14
14
  }
15
15
 
16
- .hk-phone-chip-flag {
17
- flex: none;
18
- font-size: calc(var(--text-sm, 14px) * 1.05);
19
- line-height: 1;
20
- /* Windows has no color flag font — the regional-indicator letters it
21
- * substitutes are tiny; nudge them to the visible size. */
22
- font-family: "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji",
23
- sans-serif;
24
- }
25
-
26
16
  .hk-phone-chip-dial {
27
17
  font-weight: 600;
28
18
  font-size: var(--text-sm, 14px);
@@ -101,7 +101,7 @@ describe("HkPhoneInput", () => {
101
101
  expect(events.change.at(-1)).toBe("+8613812345678");
102
102
  });
103
103
 
104
- it("lists catalog rows with flags and codes when opened", async () => {
104
+ it("lists catalog rows with names and codes when opened", async () => {
105
105
  const { container } = mountPhone();
106
106
  await openPicker(container);
107
107
  const rows = pickerRows();
@@ -109,6 +109,12 @@ describe("HkPhoneInput", () => {
109
109
  const first = rows[0];
110
110
  expect(first.textContent).toContain("China");
111
111
  expect(first.textContent).toContain("+86");
112
+ // Flags are deliberately absent — Windows has no color flag font, so
113
+ // the emoji would degrade to bare regional-indicator letters.
114
+ expect(first.querySelector(".hk-affix-row-flag")).toBeNull();
115
+ for (const row of rows) {
116
+ expect(row.querySelector(".hk-affix-row-flag")).toBeNull();
117
+ }
112
118
  });
113
119
 
114
120
  it("picks a country from the list and refocuses the number field", async () => {
@@ -7,7 +7,6 @@ import { useI18n } from "../i18n/context";
7
7
  import {
8
8
  DIAL_CODES,
9
9
  dialCodeName,
10
- flagEmoji,
11
10
  formatE164,
12
11
  normalizeDial,
13
12
  resolveDial,
@@ -21,20 +20,19 @@ import "./HkPhoneInput.scss";
21
20
  /**
22
21
  * HkPhoneInput — phone-number field with a country dial-code picker.
23
22
  *
24
- * A leading chip inside the field (flag glyph + "+86" + caret) opens
25
- * the shared HkAffixPicker (single-select, searchable): flag + country
26
- * name + dial code per row, live filter on top. The chip rides the
27
- * LEFT edge of the field — the natural reading order for "which
28
- * country, then which number" — while the number itself is typed after
29
- * it.
23
+ * A leading chip inside the field (dial code + caret) opens the shared
24
+ * HkAffixPicker (single-select, searchable): country name + dial code
25
+ * per row, live filter on top. The chip rides the LEFT edge of the
26
+ * field — the natural reading order for "which country, then which
27
+ * number" — while the number itself is typed after it.
30
28
  *
31
29
  * - `modelValue` is the NATIONAL number only ("13812345678"); the
32
30
  * country selection lives in `dialCode` ("+86" shape, normalized on
33
31
  * emit). Splitting the two keeps callers free to store whatever
34
32
  * they already store (a bare "86" works too — matched by dial).
35
- * - The chip shows the resolved country's flag plus the dial code in
36
- * canonical "+…" shape. Unknown dial codes fall back to showing
37
- * the normalized code alone (no flag).
33
+ * - The chip shows the resolved country's dial code in canonical "+…"
34
+ * shape. Unknown dial codes fall back to showing the normalized
35
+ * code alone.
38
36
  * - Picking a row emits `update:dialCode` ("+…"), `dialchange`
39
37
  * (same value) and refocuses the number field. Blurring the field
40
38
  * emits `change` with the composed E.164 — use it to validate or
@@ -111,7 +109,6 @@ export const HkPhoneInput = defineComponent({
111
109
  key: c.iso,
112
110
  label: dialCodeName(c, locale),
113
111
  meta: `+${c.dial}`,
114
- flag: flagEmoji(c.iso),
115
112
  keywords: `${c.en} ${c.zh} ${c.iso} +${c.dial} 00${c.dial}`,
116
113
  })),
117
114
  );
@@ -203,9 +200,6 @@ export const HkPhoneInput = defineComponent({
203
200
  {{
204
201
  chip: () => (
205
202
  <>
206
- <span class="hk-phone-chip-flag" aria-hidden="true">
207
- {active ? flagEmoji(active.iso) : ""}
208
- </span>
209
203
  <span class="hk-phone-chip-dial">{chipLabel()}</span>
210
204
  <ChevronDown size={12} class="hk-phone-chip-caret" aria-hidden="true" />
211
205
  </>