@celestia-island/hikari 0.40.30 → 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/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/HkPopupSelect.scss +6 -5
- package/src/components/HkProgressDialog.scss +6 -20
- package/src/components/HkProgressDialog.tsx +15 -39
- 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/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"
|
|
@@ -326,15 +326,16 @@
|
|
|
326
326
|
// Entries area — the list/grid body (state carries on data-layout)
|
|
327
327
|
// ------
|
|
328
328
|
|
|
329
|
+
/* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the entries area is NOT a
|
|
330
|
+
* scroll region — its old 24rem cap + native bar was a second scrollbar
|
|
331
|
+
* nested inside the modal window. The listing grows with its content and
|
|
332
|
+
* the HkModal body scroller (the window's ONE scrollbar) scrolls it. */
|
|
329
333
|
.hk-file-browser-entries {
|
|
330
334
|
display: flex;
|
|
331
335
|
flex-direction: column;
|
|
332
336
|
min-height: 12rem;
|
|
333
|
-
max-height: 24rem;
|
|
334
|
-
overflow-y: auto;
|
|
335
337
|
border: 1px solid var(--border-subtle);
|
|
336
338
|
border-radius: var(--radius-md);
|
|
337
|
-
overscroll-behavior: contain;
|
|
338
339
|
}
|
|
339
340
|
|
|
340
341
|
.hk-file-browser-loading {
|
|
@@ -52,27 +52,15 @@
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/*
|
|
56
|
-
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
55
|
+
/* ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the results list is NOT a
|
|
56
|
+
scroll region — its old 22rem/50vh cap + overlay rail was a second
|
|
57
|
+
scrollbar nested inside the modal window (the HkModal body scroller).
|
|
58
|
+
The list grows with its content; the modal window scrolls it. */
|
|
61
59
|
.hk-kw-search-results {
|
|
62
60
|
display: flex;
|
|
63
61
|
flex-direction: column;
|
|
64
62
|
gap: var(--hi-space-4, 0.25rem);
|
|
65
|
-
max-height: min(22rem, 50vh);
|
|
66
|
-
overflow-y: auto;
|
|
67
|
-
overflow-x: hidden;
|
|
68
63
|
padding: 0 var(--hi-space-2, 0.125rem);
|
|
69
|
-
/* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
|
|
70
|
-
hidden, never styled. */
|
|
71
|
-
scrollbar-width: none;
|
|
72
|
-
|
|
73
|
-
&::-webkit-scrollbar {
|
|
74
|
-
display: none;
|
|
75
|
-
}
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
.hk-kw-search-result {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { computed, defineComponent,
|
|
1
|
+
import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useI18n } from "../i18n/context";
|
|
4
4
|
|
|
5
5
|
import "./HkKeywordSearchModal.scss";
|
|
6
6
|
import HModal from "./HkModal";
|
|
7
7
|
import { scheduleCronAfter, type CronHandle } from "../runtime/cronBus";
|
|
8
|
-
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
9
8
|
|
|
10
9
|
interface FuzzyMatch {
|
|
11
10
|
matched: boolean;
|
|
@@ -161,21 +160,21 @@ export default defineComponent({
|
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
const resultsRef = ref<HTMLDivElement | null>(null);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
163
|
+
/** ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the results list no
|
|
164
|
+
* longer scrolls itself (its old 22rem/50vh cap + overlay rail was a
|
|
165
|
+
* second scrollbar nested inside the modal window). The HkModal
|
|
166
|
+
* body scroller is THE scrollbar; new results reset ITS position. */
|
|
167
|
+
function resetResultsScroll(): void {
|
|
168
|
+
resultsRef.value
|
|
169
|
+
?.closest<HTMLElement>(".hk-modal-body-scroll")
|
|
170
|
+
?.scrollTo({ top: 0 });
|
|
171
|
+
}
|
|
173
172
|
|
|
174
|
-
watch(debouncedQuery, (
|
|
175
|
-
if (semanticActive.value) void runSemantic(
|
|
173
|
+
watch(debouncedQuery, () => {
|
|
174
|
+
if (semanticActive.value) void runSemantic(debouncedQuery.value);
|
|
176
175
|
// New results replace the list (fuzzy + semantic paths both
|
|
177
|
-
// recompute from debouncedQuery): return the
|
|
178
|
-
|
|
176
|
+
// recompute from debouncedQuery): return the window to the top.
|
|
177
|
+
resetResultsScroll();
|
|
179
178
|
});
|
|
180
179
|
|
|
181
180
|
const results = computed(() => {
|
|
@@ -189,33 +188,22 @@ export default defineComponent({
|
|
|
189
188
|
|
|
190
189
|
watch(
|
|
191
190
|
() => props.modelValue,
|
|
192
|
-
(
|
|
193
|
-
if (
|
|
191
|
+
() => {
|
|
192
|
+
if (props.modelValue) {
|
|
194
193
|
query.value = "";
|
|
195
194
|
debouncedQuery.value = "";
|
|
196
195
|
semanticResults.value = [];
|
|
197
196
|
semanticLoading.value = false;
|
|
198
|
-
// The results list mounts with the modal body — attach the
|
|
199
|
-
// overlay scrollbar (shared chrome) once the DOM has landed;
|
|
200
|
-
// detach on close so nothing leaks in the modal portal.
|
|
201
|
-
void nextTick(() => {
|
|
202
|
-
if (!props.modelValue || !resultsRef.value) return;
|
|
203
|
-
resultsScrollbar?.detach();
|
|
204
|
-
// Exact host = the results wrapper (see the render): the
|
|
205
|
-
// broader modal body also holds the search-input row, and a
|
|
206
|
-
// rail spanning that would light up in the wrong place.
|
|
207
|
-
resultsScrollbar = attachOverlayScrollbars(resultsRef.value, {
|
|
208
|
-
axis: "vertical",
|
|
209
|
-
host: resultsWrapRef.value ?? undefined,
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
197
|
} else {
|
|
213
|
-
|
|
214
|
-
resultsScrollbar = null;
|
|
198
|
+
semanticLoading.value = false;
|
|
215
199
|
}
|
|
216
200
|
},
|
|
217
201
|
);
|
|
218
202
|
|
|
203
|
+
onBeforeUnmount(() => {
|
|
204
|
+
debounceTimer?.disconnect();
|
|
205
|
+
});
|
|
206
|
+
|
|
219
207
|
function pick(rec: unknown) {
|
|
220
208
|
emit("select", rec);
|
|
221
209
|
emit("update:modelValue", false);
|
|
@@ -278,8 +266,9 @@ export default defineComponent({
|
|
|
278
266
|
)}
|
|
279
267
|
</div>
|
|
280
268
|
|
|
281
|
-
|
|
282
|
-
|
|
269
|
+
{/* No inner scroll region — the modal window is THE scroller
|
|
270
|
+
(ONE SCROLLBAR PER WINDOW, see resetResultsScroll). */}
|
|
271
|
+
<div class="hk-kw-search-results" ref={resultsRef}>
|
|
283
272
|
{semanticActive.value ? (
|
|
284
273
|
semanticLoading.value && semanticResults.value.length === 0 ? (
|
|
285
274
|
<div class="hk-kw-search-empty">
|
|
@@ -336,7 +325,6 @@ export default defineComponent({
|
|
|
336
325
|
</button>
|
|
337
326
|
))
|
|
338
327
|
)}
|
|
339
|
-
</div>
|
|
340
328
|
</div>
|
|
341
329
|
</div>
|
|
342
330
|
</HModal>
|
|
@@ -133,27 +133,30 @@ function pickerTags(): HTMLElement[] {
|
|
|
133
133
|
return [...document.querySelectorAll<HTMLElement>(".hk-affix-tag")];
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
/** Primary texts of the tags — each language's stored value, or the
|
|
137
|
+
* italic "Not set" placeholder for the edited-but-unfilled one. */
|
|
138
|
+
function tagTexts(): string[] {
|
|
137
139
|
return pickerTags().map(
|
|
138
140
|
(t) => t.querySelector(".hk-affix-tag-text")?.textContent ?? "",
|
|
139
141
|
);
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
/** The tag whose
|
|
143
|
-
|
|
144
|
+
/** The tag whose locale-code suffix is `code` (the code suffix is the
|
|
145
|
+
* stable identity — the primary text is the language's VALUE). */
|
|
146
|
+
function tagByCode(code: string): HTMLElement | undefined {
|
|
144
147
|
return pickerTags().find(
|
|
145
|
-
(t) =>
|
|
148
|
+
(t) => t.querySelector(".hk-affix-tag-meta")?.textContent === code,
|
|
146
149
|
);
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/** The tag body (switch target) of the language tag. */
|
|
150
|
-
function tagBody(
|
|
151
|
-
return
|
|
153
|
+
function tagBody(code: string): HTMLButtonElement | undefined {
|
|
154
|
+
return tagByCode(code)?.querySelector<HTMLButtonElement>(".hk-affix-tag-body") ?? undefined;
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
/** The × of the language tag (opens the confirm dialog). */
|
|
155
|
-
function tagX(
|
|
156
|
-
return
|
|
158
|
+
function tagX(code: string): HTMLButtonElement | undefined {
|
|
159
|
+
return tagByCode(code)?.querySelector<HTMLButtonElement>(".hk-affix-tag-x") ?? undefined;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
/** The message box's confirm/cancel buttons (mounted at body level). */
|
|
@@ -164,10 +167,6 @@ function boxButton(confirm: boolean): HTMLButtonElement {
|
|
|
164
167
|
return btn!;
|
|
165
168
|
}
|
|
166
169
|
|
|
167
|
-
/** Full erase flow: the × opens the shared confirm dialog naming the
|
|
168
|
-
* entry; the dialog's Confirm erases. The leaving tag lingers through
|
|
169
|
-
* its transition window (jsdom has no CSS engine, so the ghost clears
|
|
170
|
-
* on the next-frame fallback) — poll until the tag is really gone. */
|
|
171
170
|
/** Poll until the condition turns truthy (box leave animations and
|
|
172
171
|
* tag transitions lag a few frames behind the click). */
|
|
173
172
|
async function until(condition: () => boolean, what: string): Promise<void> {
|
|
@@ -201,8 +200,13 @@ async function untilBoxOpen(label: string): Promise<void> {
|
|
|
201
200
|
).toContain(label);
|
|
202
201
|
}
|
|
203
202
|
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
/** Full erase flow: the × opens the shared confirm dialog naming the
|
|
204
|
+
* entry (by its language label); the dialog's Confirm erases. The
|
|
205
|
+
* leaving tag lingers through its transition window (jsdom has no CSS
|
|
206
|
+
* engine, so the ghost clears on the next-frame fallback) — poll until
|
|
207
|
+
* the tag is really gone. */
|
|
208
|
+
async function eraseViaConfirm(code: string, label: string, tagGone = true) {
|
|
209
|
+
tagX(code)!.click();
|
|
206
210
|
await untilBoxOpen(label);
|
|
207
211
|
boxButton(true).click();
|
|
208
212
|
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on confirm");
|
|
@@ -210,7 +214,7 @@ async function eraseViaConfirm(label: string, tagGone = true) {
|
|
|
210
214
|
// no other language remains (it stays as the active tag), so the
|
|
211
215
|
// absence wait only applies to genuinely-removed entries.
|
|
212
216
|
if (tagGone) {
|
|
213
|
-
await until(() => !
|
|
217
|
+
await until(() => !tagByCode(code), `tag "${code}" erased`);
|
|
214
218
|
} else {
|
|
215
219
|
await nextTick();
|
|
216
220
|
await nextTick();
|
|
@@ -282,9 +286,11 @@ describe("HkLocalizedInput", () => {
|
|
|
282
286
|
// The chip stays code-free while the popup is closed.
|
|
283
287
|
expect(queryChip(container).textContent).not.toContain("(zh-Hans)");
|
|
284
288
|
await openPicker(container);
|
|
285
|
-
// Tags show the
|
|
286
|
-
|
|
289
|
+
// Tags show the language's VALUE plus a muted code suffix — no
|
|
290
|
+
// parens form, and never the autonym as the primary text.
|
|
291
|
+
const zhTag = tagByCode("zh-Hans");
|
|
287
292
|
expect(zhTag, "tag for the filled translation renders").toBeTruthy();
|
|
293
|
+
expect(zhTag!.querySelector(".hk-affix-tag-text")?.textContent).toBe("工厂总览");
|
|
288
294
|
expect(zhTag!.querySelector(".hk-affix-tag-meta")?.textContent).toBe("zh-Hans");
|
|
289
295
|
expect(zhTag!.textContent).not.toContain("(zh-Hans)");
|
|
290
296
|
// Addable rows carry their code as the muted meta too.
|
|
@@ -324,10 +330,11 @@ describe("HkLocalizedInput", () => {
|
|
|
324
330
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
325
331
|
});
|
|
326
332
|
await openPicker(container);
|
|
327
|
-
// Every language in the map is a tag — the edited
|
|
328
|
-
|
|
333
|
+
// Every language in the map is a tag showing its VALUE — the edited
|
|
334
|
+
// one marked active.
|
|
335
|
+
expect(tagTexts()).toEqual(expect.arrayContaining(["Plant overview", "工厂总览"]));
|
|
329
336
|
const activeTag = pickerTags().find((t) => t.hasAttribute("data-active"));
|
|
330
|
-
expect(activeTag?.querySelector(".hk-affix-tag-text")?.textContent).toBe("
|
|
337
|
+
expect(activeTag?.querySelector(".hk-affix-tag-text")?.textContent).toBe("Plant overview");
|
|
331
338
|
// One body + one arm/confirm × per tag.
|
|
332
339
|
for (const t of pickerTags()) {
|
|
333
340
|
expect(t.querySelector(".hk-affix-tag-body"), "body on every tag").toBeTruthy();
|
|
@@ -340,9 +347,14 @@ describe("HkLocalizedInput", () => {
|
|
|
340
347
|
it("lists the edited language even while it holds no translation", async () => {
|
|
341
348
|
const { container } = mountInput({ modelValue: "Plant overview" });
|
|
342
349
|
await openPicker(container);
|
|
343
|
-
// The field edits English with nothing stored yet — still listed,
|
|
344
|
-
|
|
345
|
-
expect(
|
|
350
|
+
// The field edits English with nothing stored yet — still listed,
|
|
351
|
+
// active, and showing the italic "Not set" placeholder.
|
|
352
|
+
expect(tagTexts()).toEqual(["Not set"]);
|
|
353
|
+
const enTag = tagByCode("en")!;
|
|
354
|
+
expect(enTag.hasAttribute("data-active")).toBe(true);
|
|
355
|
+
expect(enTag.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
|
|
356
|
+
// The autonym survives as the language's identity (title naming).
|
|
357
|
+
expect(tagBody("en")?.title).toContain("English");
|
|
346
358
|
});
|
|
347
359
|
|
|
348
360
|
it("switches to an existing language via its tag body: commits text, loads its value, closes the popup", async () => {
|
|
@@ -351,7 +363,7 @@ describe("HkLocalizedInput", () => {
|
|
|
351
363
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
352
364
|
});
|
|
353
365
|
await openPicker(container);
|
|
354
|
-
const body = tagBody("
|
|
366
|
+
const body = tagBody("zh-Hans");
|
|
355
367
|
expect(body).toBeTruthy();
|
|
356
368
|
body!.click();
|
|
357
369
|
await nextTick();
|
|
@@ -440,8 +452,8 @@ describe("HkLocalizedInput", () => {
|
|
|
440
452
|
mounts.push({ app, container });
|
|
441
453
|
expect(queryChip(container).disabled).toBe(false);
|
|
442
454
|
await openPicker(container);
|
|
443
|
-
// Only the current language's tag remains in the list.
|
|
444
|
-
expect(
|
|
455
|
+
// Only the current language's tag remains in the list (its value).
|
|
456
|
+
expect(tagTexts()).toEqual(["Plant overview"]);
|
|
445
457
|
});
|
|
446
458
|
|
|
447
459
|
it("keeps edits on the switched-from language when the text is blank", async () => {
|
|
@@ -450,7 +462,7 @@ describe("HkLocalizedInput", () => {
|
|
|
450
462
|
translations: { "zh-Hans": "工厂总览" },
|
|
451
463
|
});
|
|
452
464
|
await openPicker(container);
|
|
453
|
-
const body = tagBody("
|
|
465
|
+
const body = tagBody("zh-Hans");
|
|
454
466
|
body!.click();
|
|
455
467
|
await nextTick();
|
|
456
468
|
await nextTick();
|
|
@@ -465,7 +477,7 @@ describe("HkLocalizedInput", () => {
|
|
|
465
477
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
466
478
|
});
|
|
467
479
|
await openPicker(container);
|
|
468
|
-
const body = tagBody("
|
|
480
|
+
const body = tagBody("zh-Hans");
|
|
469
481
|
body!.click();
|
|
470
482
|
await nextTick();
|
|
471
483
|
await nextTick();
|
|
@@ -520,7 +532,7 @@ describe("HkLocalizedInput", () => {
|
|
|
520
532
|
app.mount(container);
|
|
521
533
|
mounts.push({ app, container });
|
|
522
534
|
await openPicker(container);
|
|
523
|
-
expect(
|
|
535
|
+
expect(tagByCode("zh-Hans")?.querySelector(".hk-affix-tag-flag")?.textContent).toBe(flagOf("cn"));
|
|
524
536
|
});
|
|
525
537
|
|
|
526
538
|
it("follows a sourceLang change without stealing focus", async () => {
|
|
@@ -566,14 +578,14 @@ describe("HkLocalizedInput", () => {
|
|
|
566
578
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
567
579
|
});
|
|
568
580
|
await openPicker(container);
|
|
569
|
-
tagX("
|
|
581
|
+
tagX("zh-Hans")!.click();
|
|
570
582
|
await untilBoxOpen("简体中文");
|
|
571
583
|
// The dialog names the entry and carries a danger-toned confirm.
|
|
572
584
|
expect(boxButton(true).className).toContain("hk-btn-danger");
|
|
573
585
|
// Cancel → nothing is erased, the dialog closes.
|
|
574
586
|
boxButton(false).click();
|
|
575
587
|
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
|
|
576
|
-
await until(() => !!
|
|
588
|
+
await until(() => !!tagByCode("zh-Hans"), "tag stays after cancel");
|
|
577
589
|
});
|
|
578
590
|
|
|
579
591
|
it("names the tag body by its switch action and the × by its remove intent", async () => {
|
|
@@ -582,8 +594,8 @@ describe("HkLocalizedInput", () => {
|
|
|
582
594
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
583
595
|
});
|
|
584
596
|
await openPicker(container);
|
|
585
|
-
expect(tagBody("
|
|
586
|
-
expect(tagX("
|
|
597
|
+
expect(tagBody("zh-Hans")?.getAttribute("aria-label")).toBe("Switch to 简体中文");
|
|
598
|
+
expect(tagX("zh-Hans")?.getAttribute("aria-label")).toBe("Remove — 简体中文");
|
|
587
599
|
});
|
|
588
600
|
|
|
589
601
|
it("keeps the picker usable after a dismissed delete dialog", async () => {
|
|
@@ -592,13 +604,13 @@ describe("HkLocalizedInput", () => {
|
|
|
592
604
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
593
605
|
});
|
|
594
606
|
await openPicker(container);
|
|
595
|
-
tagX("
|
|
607
|
+
tagX("zh-Hans")!.click();
|
|
596
608
|
await untilBoxOpen("简体中文");
|
|
597
609
|
boxButton(false).click();
|
|
598
610
|
await until(() => !document.body.querySelector(".hk-message-box-text"), "dialog closes on cancel");
|
|
599
611
|
// The dialog never leaves the picker half-broken: the tag can still
|
|
600
612
|
// switch the edited language right after a dismissal.
|
|
601
|
-
tagBody("
|
|
613
|
+
tagBody("zh-Hans")!.click();
|
|
602
614
|
await nextTick();
|
|
603
615
|
await nextTick();
|
|
604
616
|
expect(document.activeElement).toBe(queryField(container));
|
|
@@ -610,7 +622,7 @@ describe("HkLocalizedInput", () => {
|
|
|
610
622
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
611
623
|
});
|
|
612
624
|
await openPicker(container);
|
|
613
|
-
await eraseViaConfirm("简体中文");
|
|
625
|
+
await eraseViaConfirm("zh-Hans", "简体中文");
|
|
614
626
|
// The map loses only the erased language; no edit-state events fire.
|
|
615
627
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
616
628
|
expect(events.modelValue).toEqual([]);
|
|
@@ -618,7 +630,7 @@ describe("HkLocalizedInput", () => {
|
|
|
618
630
|
expect(queryField(container).value).toBe("Plant overview");
|
|
619
631
|
expect(queryChip(container).textContent).toContain("English");
|
|
620
632
|
// The popup STAYS open and the list updates live.
|
|
621
|
-
expect(
|
|
633
|
+
expect(tagTexts()).toEqual(["Plant overview"]);
|
|
622
634
|
expect(pickerRows().some((r) => (r.textContent ?? "").includes("日本語"))).toBe(true);
|
|
623
635
|
});
|
|
624
636
|
|
|
@@ -628,10 +640,10 @@ describe("HkLocalizedInput", () => {
|
|
|
628
640
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览", ja: "プラント概覧" },
|
|
629
641
|
});
|
|
630
642
|
await openPicker(container);
|
|
631
|
-
await eraseViaConfirm("简体中文");
|
|
632
|
-
await eraseViaConfirm("日本語");
|
|
643
|
+
await eraseViaConfirm("zh-Hans", "简体中文");
|
|
644
|
+
await eraseViaConfirm("ja", "日本語");
|
|
633
645
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
634
|
-
expect(
|
|
646
|
+
expect(tagTexts()).toEqual(["Plant overview"]);
|
|
635
647
|
});
|
|
636
648
|
|
|
637
649
|
it("falls back to the source language when the edited language is erased", async () => {
|
|
@@ -643,20 +655,20 @@ describe("HkLocalizedInput", () => {
|
|
|
643
655
|
// Switch to zh-Hans first so erasing it means erasing the edited
|
|
644
656
|
// language while the source still holds a translation.
|
|
645
657
|
await openPicker(container);
|
|
646
|
-
tagBody("
|
|
658
|
+
tagBody("zh-Hans")!.click();
|
|
647
659
|
await nextTick();
|
|
648
660
|
await nextTick();
|
|
649
661
|
expect(queryChip(container).textContent).toContain("简体中文");
|
|
650
662
|
expect(queryChip(container).textContent).not.toContain("(zh-Hans)");
|
|
651
663
|
await openPicker(container);
|
|
652
|
-
await eraseViaConfirm("简体中文");
|
|
664
|
+
await eraseViaConfirm("zh-Hans", "简体中文");
|
|
653
665
|
expect(events.translations.at(-1)).toEqual({ en: "Plant overview" });
|
|
654
666
|
expect(events.modelValue.at(-1)).toBe("Plant overview");
|
|
655
667
|
expect(events.languagechange.at(-1)).toBe("en");
|
|
656
668
|
expect(queryChip(container).textContent).toContain("English");
|
|
657
669
|
expect(queryChip(container).textContent).not.toContain("(en)");
|
|
658
670
|
// The popup stays open after an erase.
|
|
659
|
-
expect(
|
|
671
|
+
expect(tagTexts()).toEqual(["Plant overview"]);
|
|
660
672
|
});
|
|
661
673
|
|
|
662
674
|
it("falls back to the first remaining translation when the source language is erased", async () => {
|
|
@@ -666,7 +678,7 @@ describe("HkLocalizedInput", () => {
|
|
|
666
678
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
667
679
|
});
|
|
668
680
|
await openPicker(container);
|
|
669
|
-
await eraseViaConfirm("English");
|
|
681
|
+
await eraseViaConfirm("en", "English");
|
|
670
682
|
expect(events.translations.at(-1)).toEqual({ "zh-Hans": "工厂总览" });
|
|
671
683
|
expect(events.modelValue.at(-1)).toBe("工厂总览");
|
|
672
684
|
expect(events.languagechange.at(-1)).toBe("zh-Hans");
|
|
@@ -680,12 +692,13 @@ describe("HkLocalizedInput", () => {
|
|
|
680
692
|
translations: { en: "Plant overview" },
|
|
681
693
|
});
|
|
682
694
|
await openPicker(container);
|
|
683
|
-
await eraseViaConfirm("English", false);
|
|
695
|
+
await eraseViaConfirm("en", "English", false);
|
|
684
696
|
expect(events.translations.at(-1)).toEqual({});
|
|
685
697
|
expect(events.modelValue.at(-1)).toBe("");
|
|
686
|
-
// The edited language tag stays (active); the popup
|
|
687
|
-
|
|
688
|
-
expect(
|
|
698
|
+
// The edited language tag stays (active, italic Not set); the popup
|
|
699
|
+
// stays open.
|
|
700
|
+
expect(tagTexts()).toEqual(["Not set"]);
|
|
701
|
+
expect(tagByCode("en")?.hasAttribute("data-active")).toBe(true);
|
|
689
702
|
expect(pickerRows().some((r) => (r.textContent ?? "").includes("日本語"))).toBe(true);
|
|
690
703
|
});
|
|
691
704
|
|
|
@@ -695,7 +708,7 @@ describe("HkLocalizedInput", () => {
|
|
|
695
708
|
translations: { en: "Plant overview", "zh-Hans": "工厂总览" },
|
|
696
709
|
});
|
|
697
710
|
await openPicker(container);
|
|
698
|
-
tagBody("
|
|
711
|
+
tagBody("en")!.click();
|
|
699
712
|
await nextTick();
|
|
700
713
|
await nextTick();
|
|
701
714
|
await untilPickerSettled();
|
|
@@ -723,4 +736,34 @@ describe("HkLocalizedInput", () => {
|
|
|
723
736
|
expect(events.modelValue.at(-1)).toBe("Line one\nLine two");
|
|
724
737
|
expect(events.translations.at(-1)).toEqual({ en: "Line one\nLine two" });
|
|
725
738
|
});
|
|
739
|
+
|
|
740
|
+
it("tags show stored values; an unfilled language vanishes once left", async () => {
|
|
741
|
+
const { events, container } = mountInput({
|
|
742
|
+
modelValue: "",
|
|
743
|
+
translations: { "zh-Hans": "工厂总览" },
|
|
744
|
+
});
|
|
745
|
+
await openPicker(container);
|
|
746
|
+
// Filled language → its value as the primary text; the edited-but-
|
|
747
|
+
// unfilled source language → the italic Not set placeholder (the
|
|
748
|
+
// only tag that can ever be empty).
|
|
749
|
+
expect(tagTexts()).toEqual(["工厂总览", "Not set"]);
|
|
750
|
+
expect(tagByCode("en")!.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(true);
|
|
751
|
+
expect(tagByCode("zh-Hans")!.querySelector(".hk-affix-tag-text")?.hasAttribute("data-unset")).toBe(false);
|
|
752
|
+
// The autonym + code still identify the language beside the value.
|
|
753
|
+
expect(tagBody("zh-Hans")!.title).toContain("简体中文");
|
|
754
|
+
expect(tagByCode("zh-Hans")!.querySelector(".hk-affix-tag-meta")?.textContent).toBe("zh-Hans");
|
|
755
|
+
// Switching away from the unfilled language (adding ja moves the
|
|
756
|
+
// field there): "en" never entered translations, so it leaves the
|
|
757
|
+
// list — the Not set state only ever marks the CURRENT edit.
|
|
758
|
+
const jaRow = pickerRows().find((r) => (r.textContent ?? "").includes("日本語"));
|
|
759
|
+
jaRow!.click();
|
|
760
|
+
await nextTick();
|
|
761
|
+
await nextTick();
|
|
762
|
+
expect(events.languagechange.at(-1)).toBe("ja");
|
|
763
|
+
await untilPickerSettled();
|
|
764
|
+
await openPicker(container);
|
|
765
|
+
expect(tagByCode("en")).toBeUndefined();
|
|
766
|
+
expect(tagTexts()).toEqual(["工厂总览", "Not set"]);
|
|
767
|
+
expect(tagByCode("ja")).toBeTruthy();
|
|
768
|
+
});
|
|
726
769
|
});
|
|
@@ -14,9 +14,11 @@ export interface HkLocaleOption {
|
|
|
14
14
|
code: string;
|
|
15
15
|
/** Display label — apps pass the SAME text their global language
|
|
16
16
|
* switcher shows (e.g. the autonym "简体中文" / "English"). The chip
|
|
17
|
-
* renders the bare label; the menu's language tags render the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* renders the bare label; the menu's language tags render the
|
|
18
|
+
* language's stored translation (or the italic "Not set") plus a
|
|
19
|
+
* small code suffix, and the label survives as the tag's title /
|
|
20
|
+
* aria / confirm-dialog naming; the add-language rows render the
|
|
21
|
+
* label plus the code. */
|
|
20
22
|
label: string;
|
|
21
23
|
/** Optional flag glyph rendered in the menu tags, matching the app's
|
|
22
24
|
* language switcher rows when they carry one. */
|
|
@@ -36,11 +38,19 @@ export interface HkLocaleOption {
|
|
|
36
38
|
* - Click the chip → the shared HkAffixPicker (multi-select, right
|
|
37
39
|
* anchored, closes on pick so the field is immediately editable):
|
|
38
40
|
* · a TAG LIST of every language currently present — the one being
|
|
39
|
-
* edited carries the active dot.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
* edited carries the active dot. A tag's primary text is that
|
|
42
|
+
* language's CURRENT TRANSLATION (value-driven `tagValues`), not
|
|
43
|
+
* its autonym; the language being edited but not yet filled
|
|
44
|
+
* renders an italic muted "Not set" placeholder (the only tag
|
|
45
|
+
* that can be empty — an unfilled language vanishes from the
|
|
46
|
+
* list the moment the field switches away, since empty values
|
|
47
|
+
* never enter `translations`). The autonym and locale code stay
|
|
48
|
+
* on the tag as title/aria naming and the muted code suffix.
|
|
49
|
+
* The × on a tag arms the delete (danger tint) and a second tap
|
|
50
|
+
* erases; the popup stays open after removals so several
|
|
51
|
+
* translations can be wiped in one pass, with squeeze-in /
|
|
52
|
+
* squeeze-out list transitions (HkListTransition,
|
|
53
|
+
* animation-context aware);
|
|
44
54
|
* · a SEARCHABLE list of the languages NOT yet present — typing
|
|
45
55
|
* filters, picking adds the language and drops the field
|
|
46
56
|
* straight into edit state for it. This replaces the old
|
|
@@ -56,6 +66,8 @@ export interface HkLocaleOption {
|
|
|
56
66
|
* (as a muted suffix on each tag) so the code never burns space inside
|
|
57
67
|
* the field. The popup participates in the shared modal/dropdown stacking
|
|
58
68
|
* contexts via HkMenu's popup-manager integration — safe inside modals.
|
|
69
|
+
* The popup mounts no scroll region of its own: the window surface it
|
|
70
|
+
* opens as owns the ONE scrollbar (HkAffixPicker contract).
|
|
59
71
|
*
|
|
60
72
|
* Set `multiline` to edit long-form translations: the field becomes an
|
|
61
73
|
* auto-growing textarea (`rows` seeds the height, `autoGrow` lets it
|
|
@@ -305,6 +317,7 @@ export const HkLocalizedInput = defineComponent({
|
|
|
305
317
|
"Add language",
|
|
306
318
|
)}
|
|
307
319
|
emptyText={t("hikari::localizedInput.noMatches", "No matching language")}
|
|
320
|
+
tagValues={props.translations}
|
|
308
321
|
onSelect={(code: string) => switchLanguage(code)}
|
|
309
322
|
onRemove={(code: string) => eraseLanguage(code)}
|
|
310
323
|
>
|
|
@@ -131,8 +131,9 @@
|
|
|
131
131
|
|
|
132
132
|
// Mobile sheet form (HPopover sheetOnMobile): full-width option list with
|
|
133
133
|
// touch-sized rows (row geometry flows from the shared sheet tokens).
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
// ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the sheet PANEL is the
|
|
135
|
+
// window scroller (.hk-popover-panel.hk-is-sheet carries overflow-y:
|
|
136
|
+
// auto), so the viewport must NOT cap itself here — an inner max-height
|
|
137
|
+
// lit a second nested scrollbar inside the same window. The desktop
|
|
138
|
+
// popout keeps its 240px content cap + viewport scroll, where the
|
|
139
|
+
// viewport bar is the popup's only scrollbar.
|
|
@@ -1,27 +1,13 @@
|
|
|
1
|
-
// HkProgressDialog.scss — the log pane's chrome.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// HkProgressDialog.scss — the log pane's chrome.
|
|
2
|
+
// ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the pane is NOT a scroll
|
|
3
|
+
// region — its old 10rem cap + overlay rail was a second scrollbar nested
|
|
4
|
+
// inside the modal window. Logs grow unbounded; the HkModal body scroller
|
|
5
|
+
// (the window's ONE scrollbar) scrolls them, and the tailing watcher in
|
|
6
|
+
// HkProgressDialog.tsx keeps the latest line visible.
|
|
5
7
|
// Use theme variables with namespace to avoid conflicts
|
|
6
8
|
@use "./hikari-vars" as vars;
|
|
7
|
-
/* Track host: wraps ONLY the log pane so the rail hugs the log band,
|
|
8
|
-
not the spinner/progress bands above it. */
|
|
9
|
-
.s-progress-dialog-log-wrap {
|
|
10
|
-
position: relative;
|
|
11
|
-
}
|
|
12
9
|
|
|
13
10
|
.s-progress-dialog-log {
|
|
14
|
-
max-height: 10rem;
|
|
15
|
-
overflow: hidden auto;
|
|
16
|
-
|
|
17
|
-
/* Overlay scrollbar (useOverlayScrollbar) — the native chrome is
|
|
18
|
-
always hidden, never styled. */
|
|
19
|
-
scrollbar-width: none;
|
|
20
|
-
|
|
21
|
-
&::-webkit-scrollbar {
|
|
22
|
-
display: none;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
11
|
border-radius: 0.375rem;
|
|
26
12
|
background: rgba(0, 0, 0, 0.06);
|
|
27
13
|
padding: 0.5rem;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { defineComponent, nextTick,
|
|
1
|
+
import { defineComponent, nextTick, ref, watch } from "vue";
|
|
2
2
|
|
|
3
3
|
import { useProgressDialog } from "../composables/useProgressDialog";
|
|
4
|
-
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
5
4
|
import HModal from "./HkModal";
|
|
6
5
|
import HProgressBar from "./HkProgressBar";
|
|
7
6
|
import HSpinner from "./HkSpinner";
|
|
@@ -12,43 +11,22 @@ export default defineComponent({
|
|
|
12
11
|
setup() {
|
|
13
12
|
const state = useProgressDialog();
|
|
14
13
|
const logRef = ref<HTMLElement>();
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
function syncLogScrollbar() {
|
|
23
|
-
void nextTick(() => {
|
|
24
|
-
if (logRef.value) {
|
|
25
|
-
logScrollbar ??= attachOverlayScrollbars(logRef.value, {
|
|
26
|
-
axis: "vertical",
|
|
27
|
-
host: logWrapRef.value,
|
|
28
|
-
});
|
|
29
|
-
} else {
|
|
30
|
-
logScrollbar?.detach();
|
|
31
|
-
logScrollbar = null;
|
|
32
|
-
}
|
|
33
|
-
});
|
|
14
|
+
// ONE SCROLLBAR PER WINDOW (2026-09-08 audit): the log pane no longer
|
|
15
|
+
// scrolls itself — its old 10rem cap + overlay rail was a second
|
|
16
|
+
// scrollbar nested inside the modal window (the HkModal body
|
|
17
|
+
// scroller). Tailing now scrolls THAT window to the bottom.
|
|
18
|
+
function scrollWindowToBottom() {
|
|
19
|
+
const win = logRef.value?.closest<HTMLElement>(".hk-modal-body-scroll");
|
|
20
|
+
if (win) win.scrollTop = win.scrollHeight;
|
|
34
21
|
}
|
|
35
22
|
|
|
36
23
|
watch(
|
|
37
24
|
() => state.logs.length,
|
|
38
25
|
() => {
|
|
39
|
-
|
|
40
|
-
nextTick(() => {
|
|
41
|
-
const el = logRef.value;
|
|
42
|
-
if (el) el.scrollTop = el.scrollHeight;
|
|
43
|
-
});
|
|
26
|
+
void nextTick(scrollWindowToBottom);
|
|
44
27
|
},
|
|
45
28
|
);
|
|
46
29
|
|
|
47
|
-
onBeforeUnmount(() => {
|
|
48
|
-
logScrollbar?.detach();
|
|
49
|
-
logScrollbar = null;
|
|
50
|
-
});
|
|
51
|
-
|
|
52
30
|
return () => (
|
|
53
31
|
<HModal
|
|
54
32
|
modelValue={state.open}
|
|
@@ -68,14 +46,12 @@ export default defineComponent({
|
|
|
68
46
|
</div>
|
|
69
47
|
)}
|
|
70
48
|
{state.logs.length > 0 ? (
|
|
71
|
-
<div ref={
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
</ul>
|
|
78
|
-
</div>
|
|
49
|
+
<div ref={logRef} class="s-progress-dialog-log">
|
|
50
|
+
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
|
51
|
+
{state.logs.map((line, i) => (
|
|
52
|
+
<li key={i}>{line}</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
79
55
|
</div>
|
|
80
56
|
) : null}
|
|
81
57
|
</div>
|
|
@@ -197,6 +197,7 @@
|
|
|
197
197
|
"hikari::messageBox.ok": "OK",
|
|
198
198
|
"hikari::affixPicker.removeConfirmTitle": "Remove entry",
|
|
199
199
|
"hikari::affixPicker.removeConfirm": "Remove \"{label}\"? This cannot be undone.",
|
|
200
|
+
"hikari::affixPicker.unset": "Not set",
|
|
200
201
|
"hikari::messageBox.alertTitle": "Notice",
|
|
201
202
|
"hikari::messageBox.confirmTitle": "Please confirm",
|
|
202
203
|
"hikari::messageBox.promptTitle": "Input"
|
|
@@ -201,6 +201,7 @@
|
|
|
201
201
|
"hikari::messageBox.ok": "好",
|
|
202
202
|
"hikari::affixPicker.removeConfirmTitle": "移除条目",
|
|
203
203
|
"hikari::affixPicker.removeConfirm": "移除「{label}」?此操作不可撤销。",
|
|
204
|
+
"hikari::affixPicker.unset": "未设置",
|
|
204
205
|
"hikari::messageBox.alertTitle": "提示",
|
|
205
206
|
"hikari::messageBox.confirmTitle": "请确认",
|
|
206
207
|
"hikari::messageBox.promptTitle": "输入"
|
|
@@ -201,6 +201,7 @@
|
|
|
201
201
|
"hikari::messageBox.ok": "好",
|
|
202
202
|
"hikari::affixPicker.removeConfirmTitle": "移除項目",
|
|
203
203
|
"hikari::affixPicker.removeConfirm": "移除「{label}」?此操作無法復原。",
|
|
204
|
+
"hikari::affixPicker.unset": "未設定",
|
|
204
205
|
"hikari::messageBox.alertTitle": "提示",
|
|
205
206
|
"hikari::messageBox.confirmTitle": "請確認",
|
|
206
207
|
"hikari::messageBox.promptTitle": "輸入"
|