@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 +1 -1
- package/src/components/HkAffixPicker.scss +19 -15
- package/src/components/HkAffixPicker.singleScroll.contract.test.ts +70 -0
- package/src/components/HkAffixPicker.test.tsx +66 -2
- package/src/components/HkAffixPicker.tsx +56 -77
- package/src/components/HkErrorBoundary.tsx +3 -2
- package/src/components/HkErrorLanding.scss +138 -61
- package/src/components/HkErrorLanding.test.tsx +49 -14
- package/src/components/HkErrorLanding.tsx +108 -28
- package/src/components/HkFileBrowserDialog.scss +4 -3
- package/src/components/HkKeywordSearchModal.scss +4 -16
- package/src/components/HkKeywordSearchModal.tsx +24 -36
- package/src/components/HkLocalizedInput.test.tsx +92 -49
- package/src/components/HkLocalizedInput.tsx +21 -8
- package/src/components/HkModal.bodyrhythm.test.ts +40 -0
- package/src/components/HkModal.scss +11 -0
- package/src/components/HkPopupSelect.scss +6 -5
- package/src/components/HkProgressDialog.scss +6 -20
- package/src/components/HkProgressDialog.tsx +15 -39
- package/src/components/HkThemeToggle.tsx +8 -4
- package/src/errorReporting/HkErrorReportingOverlay.tsx +2 -2
- package/src/i18n/locales/en/components.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +1 -0
- package/src/theme/useTheme.test.ts +89 -0
- package/src/theme/useTheme.ts +22 -6
package/package.json
CHANGED
|
@@ -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
|
-
/*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
-
|
|
503
|
-
|
|
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
|
|
34
|
-
* name/message/stack in
|
|
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",
|