@celestia-island/hikari 0.55.124 → 0.55.126
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/HkPopover.closed.test.tsx +86 -0
- package/src/components/HkPopover.tsx +20 -0
- package/src/components/HkTemplateField.scss +36 -2
- package/src/components/HkTemplateField.styles.test.ts +128 -0
- package/src/components/HkTemplateField.test.tsx +325 -0
- package/src/components/HkTemplateField.tsx +182 -48
package/package.json
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HkPopover `closed` contract: the settled-leave edge hosts reclaim
|
|
3
|
+
* focus from (the template field's Escape path). Pins:
|
|
4
|
+
*
|
|
5
|
+
* - a close emits `closed` exactly once, AFTER the panel has left
|
|
6
|
+
* the DOM (that ordering is the whole point — focus is only
|
|
7
|
+
* orphaned once the panel is gone);
|
|
8
|
+
* - opening emits nothing;
|
|
9
|
+
* - unmounting while open does NOT emit (`closed` means "closed",
|
|
10
|
+
* not "disposed" — the UNMOUNT phase skips the after-leave hook).
|
|
11
|
+
*/
|
|
12
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { createApp, h, nextTick, ref } from "vue";
|
|
14
|
+
|
|
15
|
+
import HkPopover from "./HkPopover";
|
|
16
|
+
|
|
17
|
+
const apps: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const { app, container } of apps.splice(0)) {
|
|
21
|
+
app.unmount();
|
|
22
|
+
container.remove();
|
|
23
|
+
}
|
|
24
|
+
document.body
|
|
25
|
+
.querySelectorAll(".hk-popover-panel, .hk-popover-scrim")
|
|
26
|
+
.forEach((el) => el.remove());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function mountPopover() {
|
|
30
|
+
const container = document.createElement("div");
|
|
31
|
+
document.body.appendChild(container);
|
|
32
|
+
const open = ref(false);
|
|
33
|
+
const closedCalls: boolean[] = [];
|
|
34
|
+
const app = createApp({
|
|
35
|
+
render: () =>
|
|
36
|
+
h(
|
|
37
|
+
HkPopover as never,
|
|
38
|
+
{
|
|
39
|
+
modelValue: open.value,
|
|
40
|
+
title: "probe",
|
|
41
|
+
"onUpdate:modelValue": (v: boolean) => { open.value = v; },
|
|
42
|
+
onClosed: () => closedCalls.push(!!document.body.querySelector(".hk-popover-panel")),
|
|
43
|
+
},
|
|
44
|
+
() => h("div", { class: "probe-body" }, "content"),
|
|
45
|
+
),
|
|
46
|
+
});
|
|
47
|
+
app.mount(container);
|
|
48
|
+
apps.push({ app, container });
|
|
49
|
+
return { open, closedCalls };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("HkPopover closed event", () => {
|
|
53
|
+
it("fires once on a settled close, with the panel already gone", async () => {
|
|
54
|
+
const { open, closedCalls } = mountPopover();
|
|
55
|
+
await nextTick();
|
|
56
|
+
expect(closedCalls).toHaveLength(0); // opening emits nothing
|
|
57
|
+
|
|
58
|
+
open.value = true;
|
|
59
|
+
await nextTick();
|
|
60
|
+
expect(document.body.querySelector(".hk-popover-panel")).not.toBeNull();
|
|
61
|
+
|
|
62
|
+
open.value = false;
|
|
63
|
+
await nextTick();
|
|
64
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
65
|
+
await nextTick();
|
|
66
|
+
|
|
67
|
+
expect(closedCalls).toHaveLength(1);
|
|
68
|
+
// The edge that matters: by the time `closed` ran, the panel had
|
|
69
|
+
// left the DOM (focus orphaned), not merely started leaving.
|
|
70
|
+
expect(closedCalls[0]).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not emit on unmount-while-open", async () => {
|
|
74
|
+
const { open, closedCalls } = mountPopover();
|
|
75
|
+
await nextTick();
|
|
76
|
+
open.value = true;
|
|
77
|
+
await nextTick();
|
|
78
|
+
expect(document.body.querySelector(".hk-popover-panel")).not.toBeNull();
|
|
79
|
+
|
|
80
|
+
apps.pop()!.app.unmount();
|
|
81
|
+
await nextTick();
|
|
82
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
83
|
+
await nextTick();
|
|
84
|
+
expect(closedCalls).toHaveLength(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -76,6 +76,18 @@ export default defineComponent({
|
|
|
76
76
|
},
|
|
77
77
|
emits: {
|
|
78
78
|
"update:modelValue": (_v: boolean) => true,
|
|
79
|
+
/** Fired once the close has fully settled (the leave transition
|
|
80
|
+
* completed and the panel left the DOM) — the moment focus that
|
|
81
|
+
* lived inside the closing panel falls back to <body>. Hosts that
|
|
82
|
+
* reclaim focus after an Escape-style close want THIS edge, not a
|
|
83
|
+
* timer: a fixed delay either races a long leave animation or
|
|
84
|
+
* waits past a short one.
|
|
85
|
+
*
|
|
86
|
+
* The emit rides a nextTick after the machine's closed phase, so a
|
|
87
|
+
* component UNMOUNTED in that same window drops it (Vue skips
|
|
88
|
+
* emits on unmounted instances) — a host that itself is going away
|
|
89
|
+
* has nothing to reclaim anyway. */
|
|
90
|
+
closed: () => true,
|
|
79
91
|
},
|
|
80
92
|
setup(props, { emit, slots }) {
|
|
81
93
|
const attrs = useAttrs();
|
|
@@ -450,6 +462,14 @@ export default defineComponent({
|
|
|
450
462
|
|
|
451
463
|
function onPopupAfterLeave() {
|
|
452
464
|
fullCleanup();
|
|
465
|
+
// Emit AFTER the patch that removes the panel: the machine's
|
|
466
|
+
// closed phase triggers the re-render, so emitting inline would
|
|
467
|
+
// run hosts' handlers while the panel is still in the DOM (and
|
|
468
|
+
// focus still sits inside it) — the literal meaning of "closed"
|
|
469
|
+
// is only true once the vnode has been patched away.
|
|
470
|
+
nextTick(() => {
|
|
471
|
+
emit("closed");
|
|
472
|
+
});
|
|
453
473
|
}
|
|
454
474
|
|
|
455
475
|
// Overlay scrollbar (shared chrome) — only the mobile sheet scrolls
|
|
@@ -161,6 +161,28 @@
|
|
|
161
161
|
padding: var(--space-4, 4px);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// The group is a role="group" WRAPPER carrying the accessible name in
|
|
165
|
+
// aria-label; the visible heading text is the inner label band (hidden
|
|
166
|
+
// from the a11y tree — the wrapper already announced the name).
|
|
167
|
+
.hk-tpl-group {
|
|
168
|
+
// Flex like the sibling rows list, so the parent's row gap keeps
|
|
169
|
+
// applying INSIDE the wrapper (a bare block would drop it to 0).
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
gap: 2px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.hk-tpl-group-label {
|
|
176
|
+
padding: var(--space-6, 6px) var(--space-8, 8px) var(--space-2, 2px);
|
|
177
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
178
|
+
font-weight: 600;
|
|
179
|
+
letter-spacing: 0.02em;
|
|
180
|
+
// No text-transform: labels arrive from the host (usually i18n
|
|
181
|
+
// strings) and the translator owns the casing.
|
|
182
|
+
color: var(--hi-color-muted);
|
|
183
|
+
user-select: none;
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
.hk-tpl-row {
|
|
165
187
|
display: flex;
|
|
166
188
|
flex-direction: column;
|
|
@@ -225,11 +247,23 @@
|
|
|
225
247
|
|
|
226
248
|
// ONE scrollbar per window (family contract): the mobile sheet's panel
|
|
227
249
|
// already scrolls the whole form through its own overlay scrollbar, so
|
|
228
|
-
// the rows
|
|
229
|
-
// content and otherwise has no scroll region at all).
|
|
250
|
+
// the rows viewport exists ONLY in the desktop popover (which sizes to
|
|
251
|
+
// content and otherwise has no scroll region at all). The viewport
|
|
252
|
+
// keeps native overflow MECHANICS but hides the native bar — the shared
|
|
253
|
+
// overlay scrollbar (attachOverlayScrollbars) draws the house chrome
|
|
254
|
+
// onto the dedicated host wrapper, which wraps exactly this viewport.
|
|
255
|
+
.hk-tpl-editor-scroll {
|
|
256
|
+
position: relative;
|
|
257
|
+
}
|
|
258
|
+
|
|
230
259
|
.hk-popover-panel:not(.hk-is-sheet) .hk-tpl-editor-rows {
|
|
231
260
|
max-height: min(16rem, 40dvh);
|
|
232
261
|
overflow-y: auto;
|
|
262
|
+
scrollbar-width: none;
|
|
263
|
+
|
|
264
|
+
&::-webkit-scrollbar {
|
|
265
|
+
display: none;
|
|
266
|
+
}
|
|
233
267
|
}
|
|
234
268
|
|
|
235
269
|
.hk-tpl-editor-footer {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { compile } from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compiled-stylesheet guard for the template surfaces. Vitest stubs CSS,
|
|
10
|
+
* so the runtime suite can never catch a lost rule — this test compiles
|
|
11
|
+
* the sheet directly and pins the pieces the runtime tests cannot see:
|
|
12
|
+
*
|
|
13
|
+
* 1. the editor rows' native scrollbar is HIDDEN inside the DESKTOP
|
|
14
|
+
* branch only (`scrollbar-width: none` + `::-webkit-scrollbar {
|
|
15
|
+
* display: none }` under `.hk-popover-panel:not(.hk-is-sheet)`) —
|
|
16
|
+
* the family draws its shared overlay chrome instead, and the
|
|
17
|
+
* mobile sheet must keep the panel as the ONE scroll region;
|
|
18
|
+
* 2. the scroll host is a positioning context (`position: relative`)
|
|
19
|
+
* for the overlay rails;
|
|
20
|
+
* 3. a curated class list checks TSX→CSS (no unstyled element) and a
|
|
21
|
+
* reverse scan flags any `hk-tpl-*`/`hk-template-*` selector in the
|
|
22
|
+
* sheet that no source file renders (no dead rule).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
|
|
27
|
+
function classTokensFrom(source: string): Set<string> {
|
|
28
|
+
const found = new Set<string>();
|
|
29
|
+
for (const match of source.matchAll(/\b(?:hk|s)-[\w-]+/g)) {
|
|
30
|
+
found.add(match[0]);
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("HkTemplateField stylesheet contract", () => {
|
|
36
|
+
const tsx = readFileSync(resolve(componentDir, "HkTemplateField.tsx"), "utf8");
|
|
37
|
+
const rendererTsx = readFileSync(resolve(componentDir, "HkTemplateText.tsx"), "utf8");
|
|
38
|
+
const grammars = readFileSync(resolve(componentDir, "templateGrammar.ts"), "utf8");
|
|
39
|
+
const css = compile(resolve(componentDir, "HkTemplateField.scss"), {
|
|
40
|
+
style: "expanded",
|
|
41
|
+
}).css;
|
|
42
|
+
|
|
43
|
+
it("hides the native scrollbar of the editor rows viewport (desktop branch only)", () => {
|
|
44
|
+
// Both spellings: standard property + the WebKit pseudo-element,
|
|
45
|
+
// and both under the `:not(.hk-is-sheet)` scoping — a bare rule
|
|
46
|
+
// would also strip the sheet's native bar while no overlay is
|
|
47
|
+
// attached there (sheet attaches none; the panel owns its scroll).
|
|
48
|
+
expect(css).toMatch(
|
|
49
|
+
/\.hk-popover-panel:not\(\.hk-is-sheet\)[^{]*\.hk-tpl-editor-rows[^{]*\{[^}]*scrollbar-width:\s*none/s,
|
|
50
|
+
);
|
|
51
|
+
// The WebKit spelling gets the SAME desktop scoping (R2 M8c: the
|
|
52
|
+
// unscoped form of this rule survived every assertion).
|
|
53
|
+
expect(css).toMatch(
|
|
54
|
+
/\.hk-popover-panel:not\(\.hk-is-sheet\)[^{]*hk-tpl-editor-rows[^{]*::-webkit-scrollbar[^{]*\{[^}]*display:\s*none/s,
|
|
55
|
+
);
|
|
56
|
+
// EVERY rule that hides a native bar must carry the desktop scoping
|
|
57
|
+
// — regardless of how its selector is spelled. Two spellings hide a
|
|
58
|
+
// bar: `scrollbar-width: none` (a declaration, body) and a
|
|
59
|
+
// `::-webkit-scrollbar` marker (a SELECTOR, not a declaration — the
|
|
60
|
+
// R3 P3-1 correction). At-rule preludes (`@media …`) are skipped:
|
|
61
|
+
// the naive block splitter attributes an at-rule's inner rules to
|
|
62
|
+
// its prelude, which would false-positive on any future media query.
|
|
63
|
+
const rules = [...css.matchAll(/([^{}]+)\{([^}]*)\}/gs)];
|
|
64
|
+
let barHiding = 0;
|
|
65
|
+
for (const [, selector, body] of rules) {
|
|
66
|
+
const sel = selector ?? "";
|
|
67
|
+
if (sel.trimStart().startsWith("@")) continue;
|
|
68
|
+
const hidesBar = (body ?? "").includes("scrollbar-width: none") || sel.includes("::-webkit-scrollbar");
|
|
69
|
+
if (!hidesBar) continue;
|
|
70
|
+
barHiding += 1;
|
|
71
|
+
expect(sel, `bar-hiding rule must be sheet-scoped: ${sel}`).toContain(
|
|
72
|
+
":not(.hk-is-sheet)",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
// Extraction sanity (verification-loop rule: a 0-hit scan is a
|
|
76
|
+
// pattern bug until proven otherwise) — the sheet does hide the bar
|
|
77
|
+
// somewhere, so the loop above must have inspected at least one rule.
|
|
78
|
+
expect(barHiding).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("has no dead template selector in the sheet", () => {
|
|
82
|
+
// Reverse direction: every `hk-tpl-*` / `hk-template-*` selector in
|
|
83
|
+
// the compiled CSS must appear in one of the two source files.
|
|
84
|
+
const sources = tsx + rendererTsx + grammars;
|
|
85
|
+
const selectors = new Set<string>();
|
|
86
|
+
for (const m of css.matchAll(/\.((?:hk-tpl|hk-template)[\w-]*)/g)) {
|
|
87
|
+
selectors.add(m[1]!);
|
|
88
|
+
}
|
|
89
|
+
expect(selectors.size).toBeGreaterThan(5); // extraction sanity (R1: never trust a 0-hit)
|
|
90
|
+
for (const sel of selectors) {
|
|
91
|
+
expect(sources.includes(sel), `no source renders ${sel}`).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps the heading band on the label class, not the group wrapper", () => {
|
|
96
|
+
// The wrapper is a bare layout box; carrying the band's chrome
|
|
97
|
+
// (padding/font-weight) there would pad every row run.
|
|
98
|
+
expect(css).toMatch(/\.hk-tpl-group-label\s*\{[^}]*font-weight:\s*600/s);
|
|
99
|
+
expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*font-weight/s);
|
|
100
|
+
expect(css).not.toMatch(/\.hk-tpl-group\s*\{[^}]*padding/s);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps group headings free of text-transform (the host owns casing)", () => {
|
|
104
|
+
// R2 M13: re-adding `text-transform: uppercase` used to stay green.
|
|
105
|
+
expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("gives the scroll host a positioning context for the overlay rails", () => {
|
|
109
|
+
expect(css).toMatch(/\.hk-tpl-editor-scroll\s*\{[^}]*position:\s*relative/s);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("styles every editor class the TSX renders", () => {
|
|
113
|
+
const emitted = classTokensFrom(tsx);
|
|
114
|
+
for (const cls of ["hk-template-field", "hk-template-field-edit", "hk-template-field-box",
|
|
115
|
+
"hk-template-field-label", "hk-tpl-chip", "hk-tpl-row", "hk-tpl-rows", "hk-tpl-group",
|
|
116
|
+
"hk-tpl-editor", "hk-tpl-editor-rows", "hk-tpl-editor-scroll"]) {
|
|
117
|
+
expect(emitted.has(cls), `TSX renders ${cls}`).toBe(true);
|
|
118
|
+
expect(css.includes(`.${cls}`), `sheet styles ${cls}`).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps the read-only renderer's classes styled by the shared sheet", () => {
|
|
123
|
+
expect(rendererTsx).toContain("hk-template-text");
|
|
124
|
+
expect(css.includes(".hk-template-text")).toBe(true);
|
|
125
|
+
// The read-only chips ride the SAME chip class as the editor's.
|
|
126
|
+
expect(rendererTsx).toContain("hk-tpl-chip");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -34,6 +34,26 @@ interface MountOpts {
|
|
|
34
34
|
submitOnEnter?: () => void;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function mountWithTokens(
|
|
38
|
+
tokens: Array<{ name: string; group?: string; description?: string }>,
|
|
39
|
+
opts: MountOpts & { groupLabel?: (g: string) => string } = {},
|
|
40
|
+
) {
|
|
41
|
+
const container = document.createElement("div");
|
|
42
|
+
document.body.appendChild(container);
|
|
43
|
+
const app = createApp({
|
|
44
|
+
render: () =>
|
|
45
|
+
h(HkTemplateField, {
|
|
46
|
+
modelValue: opts.modelValue ?? "",
|
|
47
|
+
tokens,
|
|
48
|
+
groupLabel: opts.groupLabel,
|
|
49
|
+
"onUpdate:modelValue": (v: string) => opts.onUpdate?.(v),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
app.mount(container);
|
|
53
|
+
mounts.push({ app, container });
|
|
54
|
+
return container;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
function mount(opts: MountOpts = {}) {
|
|
38
58
|
const container = document.createElement("div");
|
|
39
59
|
document.body.appendChild(container);
|
|
@@ -386,3 +406,308 @@ describe("HkTemplateField vocabulary guardrails (R2 gap killers)", () => {
|
|
|
386
406
|
expect(seen.at(-1)).toBe("a {{ md5_email }} b");
|
|
387
407
|
});
|
|
388
408
|
});
|
|
409
|
+
|
|
410
|
+
describe("HkTemplateField grouped vocabulary", () => {
|
|
411
|
+
const GROUPED = [
|
|
412
|
+
{ name: "id", group: "identity" },
|
|
413
|
+
{ name: "username", group: "identity" },
|
|
414
|
+
{ name: "md5_email", group: "email" },
|
|
415
|
+
{ name: "sha256_email", group: "email" },
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
it("renders one labeled group wrapper per run in the panel", async () => {
|
|
419
|
+
const el = mountWithTokens(GROUPED);
|
|
420
|
+
await nextTick();
|
|
421
|
+
await openTrigger(el, "{{ ");
|
|
422
|
+
const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
|
|
423
|
+
// The accessible name rides on the wrapper (announced on entry);
|
|
424
|
+
// the visible text lives in the inner label band.
|
|
425
|
+
expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
|
|
426
|
+
expect(groups.every((g) => g.getAttribute("role") === "group")).toBe(true);
|
|
427
|
+
const labels = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group-label"));
|
|
428
|
+
expect(labels.map((l) => l.textContent)).toEqual(["identity", "email"]);
|
|
429
|
+
// The visible band is hidden from the a11y tree — the wrapper's
|
|
430
|
+
// aria-label already announced the name (no double reading).
|
|
431
|
+
expect(labels.every((l) => l.getAttribute("aria-hidden") === "true")).toBe(true);
|
|
432
|
+
// Rows ride INSIDE their group wrapper.
|
|
433
|
+
expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
|
|
434
|
+
expect(groups[1]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("maps group keys through groupLabel", async () => {
|
|
438
|
+
const el = mountWithTokens(GROUPED, { groupLabel: (g) => g.toUpperCase() });
|
|
439
|
+
await nextTick();
|
|
440
|
+
await openTrigger(el, "{{ ");
|
|
441
|
+
const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
|
|
442
|
+
expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["IDENTITY", "EMAIL"]);
|
|
443
|
+
expect(groups.map((g) => g.querySelector(".hk-tpl-group-label")?.textContent)).toEqual([
|
|
444
|
+
"IDENTITY",
|
|
445
|
+
"EMAIL",
|
|
446
|
+
]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("filters groups out with their members (no stray heading)", async () => {
|
|
450
|
+
const el = mountWithTokens(GROUPED);
|
|
451
|
+
await nextTick();
|
|
452
|
+
await openTrigger(el, "{{ md5");
|
|
453
|
+
const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
|
|
454
|
+
expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["email"]);
|
|
455
|
+
expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(1);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("ungrouped vocabularies render exactly as before (no headings)", async () => {
|
|
459
|
+
const el = mountWithTokens([{ name: "username" }, { name: "md5_email" }]);
|
|
460
|
+
await nextTick();
|
|
461
|
+
await openTrigger(el, "{{ ");
|
|
462
|
+
expect(document.body.querySelectorAll(".hk-tpl-group")).toHaveLength(0);
|
|
463
|
+
expect(document.body.querySelectorAll(".hk-tpl-row").length).toBeGreaterThan(0);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("groups the chip editor rows too", async () => {
|
|
467
|
+
const el = mountWithTokens(GROUPED, { modelValue: "a {{ username }} b" });
|
|
468
|
+
await nextTick();
|
|
469
|
+
editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
470
|
+
await nextTick();
|
|
471
|
+
const groups = Array.from(
|
|
472
|
+
document.body.querySelectorAll<HTMLElement>(".hk-tpl-editor .hk-tpl-group"),
|
|
473
|
+
);
|
|
474
|
+
expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("HkTemplateField editor scroll chrome", () => {
|
|
479
|
+
it("wraps the scrolling rows in a dedicated track host", async () => {
|
|
480
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
481
|
+
await nextTick();
|
|
482
|
+
editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
483
|
+
await nextTick();
|
|
484
|
+
const host = document.body.querySelector<HTMLElement>(".hk-tpl-editor-scroll");
|
|
485
|
+
expect(host).not.toBeNull();
|
|
486
|
+
// The host wraps EXACTLY the scrolling viewport (rails must not
|
|
487
|
+
// span the form's other bands).
|
|
488
|
+
expect(host!.firstElementChild?.classList.contains("hk-tpl-editor-rows")).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("HkTemplateField editor close reclaim (closed hook)", () => {
|
|
493
|
+
it("returns focus to the field after the popover settles its leave", async () => {
|
|
494
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
495
|
+
await nextTick();
|
|
496
|
+
const edit = editable(el);
|
|
497
|
+
edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
498
|
+
await nextTick();
|
|
499
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
|
|
500
|
+
expect(panel).not.toBeNull();
|
|
501
|
+
// The reclaim contract only engages when the editor HELD focus —
|
|
502
|
+
// put the caret in the search field like a real user would.
|
|
503
|
+
document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
|
|
504
|
+
await nextTick();
|
|
505
|
+
// Escape: the popover starts closing; focus sits in the search
|
|
506
|
+
// input until the leave finishes.
|
|
507
|
+
panel!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
508
|
+
await nextTick();
|
|
509
|
+
// Past the leave window the machine emits `closed`, whose handler
|
|
510
|
+
// reclaims focus from the orphaned body state.
|
|
511
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
512
|
+
await nextTick();
|
|
513
|
+
expect(document.activeElement).toBe(edit);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe("HkTemplateField editor focus ownership (R1 guards)", () => {
|
|
518
|
+
it("does NOT steal focus when an outside control takes it (real click path)", async () => {
|
|
519
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
520
|
+
await nextTick();
|
|
521
|
+
const edit = editable(el);
|
|
522
|
+
edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
523
|
+
await nextTick();
|
|
524
|
+
document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
|
|
525
|
+
await nextTick();
|
|
526
|
+
|
|
527
|
+
// A real outside click: the popover's document-level shield closes
|
|
528
|
+
// it; the user's focus must stay where the click put it.
|
|
529
|
+
const outside = document.createElement("input");
|
|
530
|
+
document.body.appendChild(outside);
|
|
531
|
+
outside.focus();
|
|
532
|
+
outside.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
533
|
+
await nextTick();
|
|
534
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
535
|
+
await nextTick();
|
|
536
|
+
expect(document.activeElement).toBe(outside);
|
|
537
|
+
outside.remove();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("still reclaims after a reopen inside the previous close's leave window", async () => {
|
|
541
|
+
// The focus latch must be re-armed on open: a programmatic reopen
|
|
542
|
+
// focuses an input that never blurred (no focusin fires), and the
|
|
543
|
+
// SECOND close would otherwise orphan focus on <body>.
|
|
544
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
545
|
+
await nextTick();
|
|
546
|
+
const edit = editable(el);
|
|
547
|
+
const chip = edit.querySelector<HTMLElement>(".hk-tpl-chip")!;
|
|
548
|
+
|
|
549
|
+
chip.click();
|
|
550
|
+
await nextTick();
|
|
551
|
+
document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
|
|
552
|
+
await nextTick();
|
|
553
|
+
// Close #1 via the panel's Escape path.
|
|
554
|
+
document.body
|
|
555
|
+
.querySelector<HTMLElement>(".hk-popover-panel")!
|
|
556
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
557
|
+
await nextTick();
|
|
558
|
+
// Reopen immediately (inside the leave window — the panel may still
|
|
559
|
+
// be unmounting).
|
|
560
|
+
chip.click();
|
|
561
|
+
await nextTick();
|
|
562
|
+
// Close #2.
|
|
563
|
+
document.body
|
|
564
|
+
.querySelector<HTMLElement>(".hk-popover-panel")!
|
|
565
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
566
|
+
await nextTick();
|
|
567
|
+
await new Promise((r) => setTimeout(r, 40));
|
|
568
|
+
await nextTick();
|
|
569
|
+
expect(document.activeElement).toBe(edit);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("re-targeting another chip while open keeps exactly one scrollbar", async () => {
|
|
573
|
+
// Whatever interaction interleaving the user produces (here: a
|
|
574
|
+
// second chip click while the editor is open — the popover's
|
|
575
|
+
// outside-click shield closes and the click handler reopens), the
|
|
576
|
+
// rows host must never end up with more than ONE rail pair. Pins
|
|
577
|
+
// the leak invariant, not any particular detach call site.
|
|
578
|
+
const el = mount({ modelValue: "a {{ username }} b {{ md5_email }}" });
|
|
579
|
+
await nextTick();
|
|
580
|
+
const edit = editable(el);
|
|
581
|
+
const chips = [...edit.querySelectorAll<HTMLElement>(".hk-tpl-chip")];
|
|
582
|
+
expect(chips).toHaveLength(2);
|
|
583
|
+
chips[0]!.click();
|
|
584
|
+
await nextTick();
|
|
585
|
+
chips[1]!.click();
|
|
586
|
+
await nextTick();
|
|
587
|
+
const hosts = document.body.querySelectorAll(".hk-tpl-editor-scroll");
|
|
588
|
+
expect(hosts).toHaveLength(1);
|
|
589
|
+
expect(hosts[0]!.querySelectorAll(".hk-scrollbar-track")).toHaveLength(1);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("detaches the scrollbar machinery on close (no live observer leak)", async () => {
|
|
593
|
+
// R2 M4: the close-path detach is load-bearing — without it the
|
|
594
|
+
// rows handle's ResizeObserver + scroll listeners stay attached to
|
|
595
|
+
// a viewport that has left the document until the field unmounts
|
|
596
|
+
// (~a bounded leak). Counting LIVE observers (not bare disconnect
|
|
597
|
+
// calls — sibling components have their own ROs) is the precise
|
|
598
|
+
// signal, matching R2's real-browser instrumentation.
|
|
599
|
+
const RealRO = globalThis.ResizeObserver;
|
|
600
|
+
const live = new Set<unknown>();
|
|
601
|
+
class TrackingRO extends RealRO {
|
|
602
|
+
constructor(cb: ResizeObserverCallback) {
|
|
603
|
+
super(cb);
|
|
604
|
+
live.add(this);
|
|
605
|
+
}
|
|
606
|
+
override disconnect(): void {
|
|
607
|
+
live.delete(this);
|
|
608
|
+
super.disconnect();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
(globalThis as unknown as Record<string, unknown>).ResizeObserver = TrackingRO;
|
|
612
|
+
try {
|
|
613
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
614
|
+
await nextTick();
|
|
615
|
+
const base = live.size;
|
|
616
|
+
const edit = editable(el);
|
|
617
|
+
edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
618
|
+
await nextTick();
|
|
619
|
+
expect(live.size, "scrollbar attached an observer").toBeGreaterThan(base);
|
|
620
|
+
|
|
621
|
+
document.body
|
|
622
|
+
.querySelector<HTMLElement>(".hk-popover-panel")!
|
|
623
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
624
|
+
await nextTick();
|
|
625
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
626
|
+
await nextTick();
|
|
627
|
+
expect(live.size, "close detached every observer it attached").toBe(base);
|
|
628
|
+
} finally {
|
|
629
|
+
(globalThis as unknown as Record<string, unknown>).ResizeObserver = RealRO;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("attaches exactly one overlay scrollbar across repeated opens", async () => {
|
|
634
|
+
const el = mount({ modelValue: "a {{ username }} b" });
|
|
635
|
+
await nextTick();
|
|
636
|
+
const edit = editable(el);
|
|
637
|
+
const chip = edit.querySelector<HTMLElement>(".hk-tpl-chip")!;
|
|
638
|
+
for (let round = 0; round < 3; round++) {
|
|
639
|
+
chip.click();
|
|
640
|
+
await nextTick();
|
|
641
|
+
const host = document.body.querySelector(".hk-tpl-editor-scroll")!;
|
|
642
|
+
expect(host.querySelectorAll(".hk-scrollbar-track")).toHaveLength(1);
|
|
643
|
+
document.body
|
|
644
|
+
.querySelector<HTMLElement>(".hk-popover-panel")!
|
|
645
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
|
646
|
+
await nextTick();
|
|
647
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
648
|
+
await nextTick();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
describe("HkTemplateField partially-grouped vocabulary", () => {
|
|
654
|
+
it("renders a heading only where a group starts (documented contract)", async () => {
|
|
655
|
+
const el = mountWithTokens([
|
|
656
|
+
{ name: "id", group: "identity" },
|
|
657
|
+
{ name: "free_a" }, // ungrouped: continues the identity band
|
|
658
|
+
{ name: "free_b" },
|
|
659
|
+
{ name: "md5_email", group: "email" },
|
|
660
|
+
{ name: "free_c" },
|
|
661
|
+
]);
|
|
662
|
+
await nextTick();
|
|
663
|
+
await openTrigger(el, "{{ ");
|
|
664
|
+
const groups = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
|
|
665
|
+
// Two wrappers for two group runs; the ungrouped tokens ride inside
|
|
666
|
+
// the open run (see the groupLabel prop doc) — free_a/b inside
|
|
667
|
+
// identity, free_c inside email.
|
|
668
|
+
expect(groups.map((g) => g.getAttribute("aria-label"))).toEqual(["identity", "email"]);
|
|
669
|
+
expect(groups[0]!.querySelectorAll(".hk-tpl-row")).toHaveLength(3);
|
|
670
|
+
expect(groups[1]!.querySelectorAll(".hk-tpl-row")).toHaveLength(2);
|
|
671
|
+
expect(document.body.querySelectorAll(".hk-tpl-row")).toHaveLength(5);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
describe("HkTemplateField vocabulary semantics guards", () => {
|
|
676
|
+
it("the rows containers carry no listbox role (menu/dialog context)", async () => {
|
|
677
|
+
const el = mountWithTokens([
|
|
678
|
+
{ name: "id", group: "identity" },
|
|
679
|
+
{ name: "md5_email", group: "email" },
|
|
680
|
+
]);
|
|
681
|
+
await nextTick();
|
|
682
|
+
await openTrigger(el, "{{ ");
|
|
683
|
+
const panelRows = document.body.querySelector<HTMLElement>(".hk-tpl-rows")!;
|
|
684
|
+
expect(panelRows.getAttribute("role")).toBeNull();
|
|
685
|
+
|
|
686
|
+
const el2 = mount({ modelValue: "a {{ username }} b" });
|
|
687
|
+
await nextTick();
|
|
688
|
+
editable(el2).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
|
|
689
|
+
await nextTick();
|
|
690
|
+
const editorRows = document.body.querySelector<HTMLElement>(".hk-tpl-editor-rows")!;
|
|
691
|
+
expect(editorRows.getAttribute("role")).toBeNull();
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("the panel's active highlight follows the filtered index inside wrappers", async () => {
|
|
695
|
+
const el = mountWithTokens([
|
|
696
|
+
{ name: "id", group: "identity" },
|
|
697
|
+
{ name: "username", group: "identity" },
|
|
698
|
+
{ name: "md5_email", group: "email" },
|
|
699
|
+
{ name: "sha256_email", group: "email" },
|
|
700
|
+
]);
|
|
701
|
+
await nextTick();
|
|
702
|
+
await openTrigger(el, "{{ ");
|
|
703
|
+
editable(el).dispatchEvent(
|
|
704
|
+
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }),
|
|
705
|
+
);
|
|
706
|
+
await nextTick();
|
|
707
|
+
const active = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-row[data-active]"));
|
|
708
|
+
expect(active).toHaveLength(1);
|
|
709
|
+
// Index 1 across the WHOLE filtered list (username) — the highlight
|
|
710
|
+
// must not restart inside each group wrapper.
|
|
711
|
+
expect(active[0]!.textContent).toContain("username");
|
|
712
|
+
});
|
|
713
|
+
});
|
|
@@ -2,9 +2,10 @@ import {
|
|
|
2
2
|
computed,
|
|
3
3
|
defineComponent,
|
|
4
4
|
nextTick,
|
|
5
|
-
|
|
5
|
+
onBeforeUnmount,
|
|
6
6
|
onMounted,
|
|
7
7
|
ref,
|
|
8
|
+
useId,
|
|
8
9
|
watch,
|
|
9
10
|
type PropType,
|
|
10
11
|
} from "vue";
|
|
@@ -12,6 +13,8 @@ import { Trash2 } from "lucide-vue-next";
|
|
|
12
13
|
|
|
13
14
|
import HButton from "./HkButton";
|
|
14
15
|
import HkInput from "./HkInput";
|
|
16
|
+
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
17
|
+
import { useBreakpoint } from "../runtime/useBreakpoint";
|
|
15
18
|
import HkMenu from "./HkMenu";
|
|
16
19
|
import HPopover from "./HkPopover";
|
|
17
20
|
import { useI18n } from "../i18n/context";
|
|
@@ -25,6 +28,12 @@ import {
|
|
|
25
28
|
} from "./templateGrammar";
|
|
26
29
|
import "./HkTemplateField.scss";
|
|
27
30
|
|
|
31
|
+
/** What renderGroupedRows hands its row callback. */
|
|
32
|
+
interface HkTemplateFieldRow {
|
|
33
|
+
def: HkTemplateTokenDef;
|
|
34
|
+
index: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
/** Matches an UNFINISHED `{{ query` the caret is typing inside — the
|
|
29
38
|
* autocomplete trigger. Identifiers only; anything else (a filter pipe,
|
|
30
39
|
* a space) drops out of trigger state and closes the panel. */
|
|
@@ -84,6 +93,16 @@ export const HkTemplateField = defineComponent({
|
|
|
84
93
|
/** Submit intent on Enter (when no suggestion row is active) —
|
|
85
94
|
* same contract as HkInput. */
|
|
86
95
|
submitOnEnter: { type: Function, default: undefined },
|
|
96
|
+
/** Maps a token's `group` to the heading label shown over its run
|
|
97
|
+
* of rows (both the vocabulary panel and the chip editor). Passed
|
|
98
|
+
* through verbatim when omitted — groups are consumer-defined
|
|
99
|
+
* technical keys, so the library has no opinion on their wording.
|
|
100
|
+
*
|
|
101
|
+
* Partially-grouped vocabularies: a token WITHOUT a `group` is not
|
|
102
|
+
* a section of its own — it renders under the previous heading's
|
|
103
|
+
* band (the heading marks where a group STARTS, not where it ends).
|
|
104
|
+
* Give every token a group when the sections must be airtight. */
|
|
105
|
+
groupLabel: { type: Function as PropType<(group: string) => string>, default: undefined },
|
|
87
106
|
},
|
|
88
107
|
emits: {
|
|
89
108
|
"update:modelValue": (_v: string) => true,
|
|
@@ -419,6 +438,41 @@ export const HkTemplateField = defineComponent({
|
|
|
419
438
|
|
|
420
439
|
const editorRows = computed(() => filterTokens(props.tokens, editorQuery.value));
|
|
421
440
|
|
|
441
|
+
// The rows list scrolls through the SHARED overlay scrollbar
|
|
442
|
+
// (house chrome — a native overflow bar inside a hikari popup is
|
|
443
|
+
// the family violation this replaces). The scroll host wraps
|
|
444
|
+
// EXACTLY the scrolling rows (rails must not span the form's other
|
|
445
|
+
// bands); sheet mode attaches nothing — the docked panel owns the
|
|
446
|
+
// window's one scrollbar there.
|
|
447
|
+
const { isMobile } = useBreakpoint();
|
|
448
|
+
const editorRowsHostRef = ref<HTMLElement | null>(null);
|
|
449
|
+
const editorRowsRef = ref<HTMLElement | null>(null);
|
|
450
|
+
let editorRowsScrollbar: OverlayScrollbarHandle | null = null;
|
|
451
|
+
|
|
452
|
+
function syncEditorRowsScrollbar(): void {
|
|
453
|
+
editorRowsScrollbar?.update();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The shared scrollbar observes the VIEWPORT box; a vocabulary edit
|
|
457
|
+
// that changes the rows' content height without resizing the viewport
|
|
458
|
+
// would leave the thumb stale until the next scroll — refresh it when
|
|
459
|
+
// the (filtered) rows change.
|
|
460
|
+
watch(editorRows, () => { nextTick(syncEditorRowsScrollbar); });
|
|
461
|
+
|
|
462
|
+
function detachEditorRowsScrollbar(): void {
|
|
463
|
+
editorRowsScrollbar?.detach();
|
|
464
|
+
editorRowsScrollbar = null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function attachEditorRowsScrollbar(): void {
|
|
468
|
+
detachEditorRowsScrollbar();
|
|
469
|
+
if (isMobile.value) return; // sheet: the panel owns the scrollbar
|
|
470
|
+
const viewport = editorRowsRef.value;
|
|
471
|
+
const host = editorRowsHostRef.value;
|
|
472
|
+
if (!viewport || !host) return;
|
|
473
|
+
editorRowsScrollbar = attachOverlayScrollbars(viewport, { axis: "vertical", host });
|
|
474
|
+
}
|
|
475
|
+
|
|
422
476
|
const editorDef = computed(() => tokenIndex.value.get(editorToken.value));
|
|
423
477
|
|
|
424
478
|
function openChipEditor(chip: HTMLElement): void {
|
|
@@ -428,6 +482,10 @@ export const HkTemplateField = defineComponent({
|
|
|
428
482
|
// panel's outside-click shield does not fire) trades it for the
|
|
429
483
|
// editor instead of stacking both.
|
|
430
484
|
closeSuggestions();
|
|
485
|
+
// A reopen cancels any reclaim still owed by the previous close's
|
|
486
|
+
// leave window — firing it now would steal focus from the freshly
|
|
487
|
+
// opened editor's search input.
|
|
488
|
+
pendingEditorReclaim.value = false;
|
|
431
489
|
editorChip.value = chip;
|
|
432
490
|
editorToken.value = chip.dataset.token ?? "";
|
|
433
491
|
editorQuery.value = "";
|
|
@@ -437,6 +495,13 @@ export const HkTemplateField = defineComponent({
|
|
|
437
495
|
// popover and the mobile sheet.
|
|
438
496
|
nextTick(() => {
|
|
439
497
|
editorSearchWrap.value?.querySelector("input")?.focus();
|
|
498
|
+
// Re-arm the focus latch explicitly: a reopen INSIDE the previous
|
|
499
|
+
// close's leave window focuses an input that (in programmatic
|
|
500
|
+
// flows) never blurred, so no focusin fires and the latch would
|
|
501
|
+
// stay false — the next close would then skip its reclaim and
|
|
502
|
+
// orphan focus on <body> (R1 S2 finding).
|
|
503
|
+
editorHoldsFocus.value = true;
|
|
504
|
+
attachEditorRowsScrollbar();
|
|
440
505
|
});
|
|
441
506
|
}
|
|
442
507
|
|
|
@@ -527,44 +592,49 @@ export const HkTemplateField = defineComponent({
|
|
|
527
592
|
editRef.value?.focus();
|
|
528
593
|
}
|
|
529
594
|
|
|
595
|
+
/** True when focus is unowned (body/nowhere) or already back on
|
|
596
|
+
* this field — the only states the editor close may reclaim it
|
|
597
|
+
* from. An outside-click close that focused another control must
|
|
598
|
+
* not have focus yanked back 3ms later (real-browser R1 finding). */
|
|
599
|
+
function editorFocusReclaimable(): boolean {
|
|
600
|
+
const active = document.activeElement;
|
|
601
|
+
return (
|
|
602
|
+
!active ||
|
|
603
|
+
active === document.body ||
|
|
604
|
+
(editRef.value ? active === editRef.value || editRef.value.contains(active) : false)
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function reclaimEditorFocus(): void {
|
|
609
|
+
if (!editorFocusReclaimable()) return;
|
|
610
|
+
editRef.value?.focus();
|
|
611
|
+
// The chip may have been re-rendered away by the time this runs
|
|
612
|
+
// (a late reclaim) — a detached reference must not steer the
|
|
613
|
+
// caret (offsetAfterChip would return the full length).
|
|
614
|
+
if (editorChip.value?.isConnected) {
|
|
615
|
+
applyCaretOffset(offsetAfterChip(editorChip.value));
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Set when a closing chip editor may still owe the field its
|
|
620
|
+
* focus back (through the leave window); cleared by the popover's
|
|
621
|
+
* settled `closed` edge or the next open. */
|
|
622
|
+
const pendingEditorReclaim = ref(false);
|
|
623
|
+
|
|
530
624
|
function closeChipEditor(): void {
|
|
531
625
|
if (!editorOpen.value) return;
|
|
532
626
|
editorOpen.value = false;
|
|
627
|
+
detachEditorRowsScrollbar();
|
|
533
628
|
if (editorHoldsFocus.value) {
|
|
534
629
|
editorHoldsFocus.value = false;
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
);
|
|
544
|
-
};
|
|
545
|
-
const reclaim = () => {
|
|
546
|
-
if (!reclaimable()) return;
|
|
547
|
-
editRef.value?.focus();
|
|
548
|
-
// The chip may have been re-rendered away by the time this
|
|
549
|
-
// runs (late timers) — a detached reference must not steer
|
|
550
|
-
// the caret (offsetAfterChip would return the full length).
|
|
551
|
-
if (editorChip.value?.isConnected) {
|
|
552
|
-
applyCaretOffset(offsetAfterChip(editorChip.value));
|
|
553
|
-
}
|
|
554
|
-
};
|
|
555
|
-
// Return focus ONLY when it did not land on something else by
|
|
556
|
-
// user intent: an outside-click close that focused another
|
|
557
|
-
// control must not yank focus back 3ms later (real-browser R1
|
|
558
|
-
// finding — focus@t → blur@t+3ms → activeElement ended up on
|
|
559
|
-
// this field, not the clicked input).
|
|
560
|
-
nextTick(reclaim);
|
|
561
|
-
// And once more past the popover's leave window: an Escape
|
|
562
|
-
// close leaves the search input holding focus THROUGH the
|
|
563
|
-
// closing animation, so the probe above correctly declines —
|
|
564
|
-
// but when the panel finishes unmounting, focus falls to
|
|
565
|
-
// <body> and nobody reclaims it. Re-run the same ownership
|
|
566
|
-
// probe after the leave budget (R2 finding).
|
|
567
|
-
setTimeout(reclaim, 400);
|
|
630
|
+
nextTick(reclaimEditorFocus);
|
|
631
|
+
// An Escape close keeps the search input holding focus THROUGH
|
|
632
|
+
// the closing animation, so the probe above correctly declines;
|
|
633
|
+
// when the panel finishes unmounting, focus falls to <body>
|
|
634
|
+
// with nobody to reclaim it. The popover's `closed` event (the
|
|
635
|
+
// settled leave edge) re-runs the same ownership probe exactly
|
|
636
|
+
// then — no timer race with a long or short leave animation.
|
|
637
|
+
pendingEditorReclaim.value = true;
|
|
568
638
|
}
|
|
569
639
|
}
|
|
570
640
|
|
|
@@ -582,6 +652,10 @@ export const HkTemplateField = defineComponent({
|
|
|
582
652
|
renderValue(props.modelValue);
|
|
583
653
|
});
|
|
584
654
|
|
|
655
|
+
onBeforeUnmount(() => {
|
|
656
|
+
detachEditorRowsScrollbar();
|
|
657
|
+
});
|
|
658
|
+
|
|
585
659
|
// ── Editable event wiring ───────────────────────────────────────
|
|
586
660
|
|
|
587
661
|
function onInput(): void {
|
|
@@ -807,6 +881,51 @@ export const HkTemplateField = defineComponent({
|
|
|
807
881
|
};
|
|
808
882
|
}
|
|
809
883
|
|
|
884
|
+
/** Grouped rows as labeled WRAPPERS, not sibling headings: a
|
|
885
|
+
* `role="group"` box carrying the group's name in `aria-label` is
|
|
886
|
+
* the pattern screen readers announce on entry (a bare
|
|
887
|
+
* role="presentation" heading band was invisible to them — the
|
|
888
|
+
* R3 P3 finding), and it is the sanctioned way to segment choices
|
|
889
|
+
* inside HkMenu's role="menu" surface. The visible heading text
|
|
890
|
+
* lives in the inner .hk-tpl-group-label band. */
|
|
891
|
+
function renderGroupedRows(
|
|
892
|
+
defs: readonly HkTemplateTokenDef[],
|
|
893
|
+
renderRow: (row: HkTemplateFieldRow) => unknown,
|
|
894
|
+
): unknown[] {
|
|
895
|
+
const out: unknown[] = [];
|
|
896
|
+
let run: { label: string; rows: unknown[] } | null = null;
|
|
897
|
+
const flush = () => {
|
|
898
|
+
if (!run) return;
|
|
899
|
+
out.push(
|
|
900
|
+
<div class="hk-tpl-group" role="group" aria-label={run.label}>
|
|
901
|
+
<div class="hk-tpl-group-label" aria-hidden="true">{run.label}</div>
|
|
902
|
+
{run.rows}
|
|
903
|
+
</div>,
|
|
904
|
+
);
|
|
905
|
+
run = null;
|
|
906
|
+
};
|
|
907
|
+
for (let i = 0; i < defs.length; i++) {
|
|
908
|
+
const def = defs[i]!;
|
|
909
|
+
if (def.group !== undefined) {
|
|
910
|
+
const label = props.groupLabel ? props.groupLabel(def.group) : def.group;
|
|
911
|
+
if (!run || run.label !== label) {
|
|
912
|
+
flush();
|
|
913
|
+
run = { label, rows: [] };
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
// Tokens without a group simply continue under the previous
|
|
917
|
+
// group's visual section — the documented contract for
|
|
918
|
+
// partially-grouped vocabularies (see the prop doc). When the
|
|
919
|
+
// run is open they ride inside it; a leading ungrouped run
|
|
920
|
+
// renders bare, exactly as an ungrouped vocabulary does.
|
|
921
|
+
const row = renderRow({ def, index: i });
|
|
922
|
+
if (run) run.rows.push(row);
|
|
923
|
+
else out.push(row);
|
|
924
|
+
}
|
|
925
|
+
flush();
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
|
|
810
929
|
const renderTokenRow = (
|
|
811
930
|
def: HkTemplateTokenDef,
|
|
812
931
|
active: boolean,
|
|
@@ -882,13 +1001,13 @@ export const HkTemplateField = defineComponent({
|
|
|
882
1001
|
title={t("hikari::templateField.suggestTitle", "Placeholders")}
|
|
883
1002
|
>
|
|
884
1003
|
{filteredTokens.value.length > 0 ? (
|
|
885
|
-
<div class="hk-tpl-rows"
|
|
886
|
-
{filteredTokens.value
|
|
1004
|
+
<div class="hk-tpl-rows">
|
|
1005
|
+
{renderGroupedRows(filteredTokens.value, ({ def, index }) =>
|
|
887
1006
|
renderTokenRow(
|
|
888
1007
|
def,
|
|
889
|
-
|
|
1008
|
+
index === Math.min(suggestActive.value, filteredTokens.value.length - 1),
|
|
890
1009
|
() => pickSuggestion(def),
|
|
891
|
-
|
|
1010
|
+
index,
|
|
892
1011
|
),
|
|
893
1012
|
)}
|
|
894
1013
|
</div>
|
|
@@ -906,6 +1025,16 @@ export const HkTemplateField = defineComponent({
|
|
|
906
1025
|
onUpdate:modelValue={(v: boolean) => {
|
|
907
1026
|
if (!v) closeChipEditor();
|
|
908
1027
|
}}
|
|
1028
|
+
onClosed={() => {
|
|
1029
|
+
if (!pendingEditorReclaim.value) return;
|
|
1030
|
+
pendingEditorReclaim.value = false;
|
|
1031
|
+
// HkPopover emits this from a nextTick AFTER the patch that
|
|
1032
|
+
// removed the panel (its own doc contract, verified in a
|
|
1033
|
+
// real browser: the handler sees no panel and focus already
|
|
1034
|
+
// fallen to <body>) — so the ownership probe can run here
|
|
1035
|
+
// directly.
|
|
1036
|
+
reclaimEditorFocus();
|
|
1037
|
+
}}
|
|
909
1038
|
anchorRef={editorChip.value}
|
|
910
1039
|
sheetOnMobile
|
|
911
1040
|
placement="bottom-start"
|
|
@@ -937,19 +1066,24 @@ export const HkTemplateField = defineComponent({
|
|
|
937
1066
|
>
|
|
938
1067
|
<HkInput
|
|
939
1068
|
modelValue={editorQuery.value}
|
|
940
|
-
onUpdate:modelValue={(v: string) => {
|
|
1069
|
+
onUpdate:modelValue={(v: string) => {
|
|
1070
|
+
editorQuery.value = v;
|
|
1071
|
+
nextTick(syncEditorRowsScrollbar);
|
|
1072
|
+
}}
|
|
941
1073
|
placeholder={t("hikari::templateField.chipSearch", "Search placeholders")}
|
|
942
1074
|
/>
|
|
943
1075
|
</div>
|
|
944
|
-
<div class="hk-tpl-editor-
|
|
945
|
-
{
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1076
|
+
<div ref={editorRowsHostRef} class="hk-tpl-editor-scroll">
|
|
1077
|
+
<div ref={editorRowsRef} class="hk-tpl-editor-rows">
|
|
1078
|
+
{renderGroupedRows(editorRows.value, ({ def }) =>
|
|
1079
|
+
renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
|
|
1080
|
+
)}
|
|
1081
|
+
{editorRows.value.length === 0 && (
|
|
1082
|
+
<p class="hk-tpl-rows-empty">
|
|
1083
|
+
{t("hikari::templateField.suggestEmpty", "No matching placeholders")}
|
|
1084
|
+
</p>
|
|
1085
|
+
)}
|
|
1086
|
+
</div>
|
|
953
1087
|
</div>
|
|
954
1088
|
<div class="hk-tpl-editor-footer">
|
|
955
1089
|
<HButton
|