@celestia-island/hikari 0.55.124 → 0.55.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.124",
3
+ "version": "0.55.125",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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,17 @@
161
161
  padding: var(--space-4, 4px);
162
162
  }
163
163
 
164
+ .hk-tpl-group {
165
+ padding: var(--space-6, 6px) var(--space-8, 8px) var(--space-2, 2px);
166
+ font-size: var(--text-2xs, 0.625rem);
167
+ font-weight: 600;
168
+ letter-spacing: 0.02em;
169
+ // No text-transform: labels arrive from the host (usually i18n
170
+ // strings) and the translator owns the casing.
171
+ color: var(--hi-color-muted);
172
+ user-select: none;
173
+ }
174
+
164
175
  .hk-tpl-row {
165
176
  display: flex;
166
177
  flex-direction: column;
@@ -225,11 +236,23 @@
225
236
 
226
237
  // ONE scrollbar per window (family contract): the mobile sheet's panel
227
238
  // already scrolls the whole form through its own overlay scrollbar, so
228
- // the rows cap exists ONLY in the desktop popover (which sizes to
229
- // content and otherwise has no scroll region at all).
239
+ // the rows viewport exists ONLY in the desktop popover (which sizes to
240
+ // content and otherwise has no scroll region at all). The viewport
241
+ // keeps native overflow MECHANICS but hides the native bar — the shared
242
+ // overlay scrollbar (attachOverlayScrollbars) draws the house chrome
243
+ // onto the dedicated host wrapper, which wraps exactly this viewport.
244
+ .hk-tpl-editor-scroll {
245
+ position: relative;
246
+ }
247
+
230
248
  .hk-popover-panel:not(.hk-is-sheet) .hk-tpl-editor-rows {
231
249
  max-height: min(16rem, 40dvh);
232
250
  overflow-y: auto;
251
+ scrollbar-width: none;
252
+
253
+ &::-webkit-scrollbar {
254
+ display: none;
255
+ }
233
256
  }
234
257
 
235
258
  .hk-tpl-editor-footer {
@@ -0,0 +1,120 @@
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 group headings free of text-transform (the host owns casing)", () => {
96
+ // R2 M13: re-adding `text-transform: uppercase` used to stay green.
97
+ expect(css).not.toMatch(/\.hk-tpl-group[^{]*\{[^}]*text-transform/s);
98
+ });
99
+
100
+ it("gives the scroll host a positioning context for the overlay rails", () => {
101
+ expect(css).toMatch(/\.hk-tpl-editor-scroll\s*\{[^}]*position:\s*relative/s);
102
+ });
103
+
104
+ it("styles every editor class the TSX renders", () => {
105
+ const emitted = classTokensFrom(tsx);
106
+ for (const cls of ["hk-template-field", "hk-template-field-edit", "hk-template-field-box",
107
+ "hk-template-field-label", "hk-tpl-chip", "hk-tpl-row", "hk-tpl-rows", "hk-tpl-group",
108
+ "hk-tpl-editor", "hk-tpl-editor-rows", "hk-tpl-editor-scroll"]) {
109
+ expect(emitted.has(cls), `TSX renders ${cls}`).toBe(true);
110
+ expect(css.includes(`.${cls}`), `sheet styles ${cls}`).toBe(true);
111
+ }
112
+ });
113
+
114
+ it("keeps the read-only renderer's classes styled by the shared sheet", () => {
115
+ expect(rendererTsx).toContain("hk-template-text");
116
+ expect(css.includes(".hk-template-text")).toBe(true);
117
+ // The read-only chips ride the SAME chip class as the editor's.
118
+ expect(rendererTsx).toContain("hk-tpl-chip");
119
+ });
120
+ });
@@ -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,251 @@ 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 heading per group run in the panel", async () => {
419
+ const el = mountWithTokens(GROUPED);
420
+ await nextTick();
421
+ await openTrigger(el, "{{ ");
422
+ const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
423
+ expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
424
+ // Headings are presentational, not options.
425
+ expect(headings.every((h) => h.getAttribute("role") === "presentation")).toBe(true);
426
+ });
427
+
428
+ it("maps group keys through groupLabel", async () => {
429
+ const el = mountWithTokens(GROUPED, { groupLabel: (g) => g.toUpperCase() });
430
+ await nextTick();
431
+ await openTrigger(el, "{{ ");
432
+ const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
433
+ expect(headings.map((h) => h.textContent)).toEqual(["IDENTITY", "EMAIL"]);
434
+ });
435
+
436
+ it("filters groups out with their members (no stray heading)", async () => {
437
+ const el = mountWithTokens(GROUPED);
438
+ await nextTick();
439
+ await openTrigger(el, "{{ md5");
440
+ const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
441
+ expect(headings.map((h) => h.textContent)).toEqual(["email"]);
442
+ });
443
+
444
+ it("ungrouped vocabularies render exactly as before (no headings)", async () => {
445
+ const el = mountWithTokens([{ name: "username" }, { name: "md5_email" }]);
446
+ await nextTick();
447
+ await openTrigger(el, "{{ ");
448
+ expect(document.body.querySelectorAll(".hk-tpl-group")).toHaveLength(0);
449
+ expect(document.body.querySelectorAll(".hk-tpl-row").length).toBeGreaterThan(0);
450
+ });
451
+
452
+ it("groups the chip editor rows too", async () => {
453
+ const el = mountWithTokens(GROUPED, { modelValue: "a {{ username }} b" });
454
+ await nextTick();
455
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
456
+ await nextTick();
457
+ const headings = Array.from(
458
+ document.body.querySelectorAll<HTMLElement>(".hk-tpl-editor .hk-tpl-group"),
459
+ );
460
+ expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
461
+ });
462
+ });
463
+
464
+ describe("HkTemplateField editor scroll chrome", () => {
465
+ it("wraps the scrolling rows in a dedicated track host", async () => {
466
+ const el = mount({ modelValue: "a {{ username }} b" });
467
+ await nextTick();
468
+ editable(el).querySelector<HTMLElement>(".hk-tpl-chip")!.click();
469
+ await nextTick();
470
+ const host = document.body.querySelector<HTMLElement>(".hk-tpl-editor-scroll");
471
+ expect(host).not.toBeNull();
472
+ // The host wraps EXACTLY the scrolling viewport (rails must not
473
+ // span the form's other bands).
474
+ expect(host!.firstElementChild?.classList.contains("hk-tpl-editor-rows")).toBe(true);
475
+ });
476
+ });
477
+
478
+ describe("HkTemplateField editor close reclaim (closed hook)", () => {
479
+ it("returns focus to the field after the popover settles its leave", async () => {
480
+ const el = mount({ modelValue: "a {{ username }} b" });
481
+ await nextTick();
482
+ const edit = editable(el);
483
+ edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
484
+ await nextTick();
485
+ const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel");
486
+ expect(panel).not.toBeNull();
487
+ // The reclaim contract only engages when the editor HELD focus —
488
+ // put the caret in the search field like a real user would.
489
+ document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
490
+ await nextTick();
491
+ // Escape: the popover starts closing; focus sits in the search
492
+ // input until the leave finishes.
493
+ panel!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
494
+ await nextTick();
495
+ // Past the leave window the machine emits `closed`, whose handler
496
+ // reclaims focus from the orphaned body state.
497
+ await new Promise((r) => setTimeout(r, 30));
498
+ await nextTick();
499
+ expect(document.activeElement).toBe(edit);
500
+ });
501
+ });
502
+
503
+ describe("HkTemplateField editor focus ownership (R1 guards)", () => {
504
+ it("does NOT steal focus when an outside control takes it (real click path)", async () => {
505
+ const el = mount({ modelValue: "a {{ username }} b" });
506
+ await nextTick();
507
+ const edit = editable(el);
508
+ edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
509
+ await nextTick();
510
+ document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
511
+ await nextTick();
512
+
513
+ // A real outside click: the popover's document-level shield closes
514
+ // it; the user's focus must stay where the click put it.
515
+ const outside = document.createElement("input");
516
+ document.body.appendChild(outside);
517
+ outside.focus();
518
+ outside.dispatchEvent(new MouseEvent("click", { bubbles: true }));
519
+ await nextTick();
520
+ await new Promise((r) => setTimeout(r, 30));
521
+ await nextTick();
522
+ expect(document.activeElement).toBe(outside);
523
+ outside.remove();
524
+ });
525
+
526
+ it("still reclaims after a reopen inside the previous close's leave window", async () => {
527
+ // The focus latch must be re-armed on open: a programmatic reopen
528
+ // focuses an input that never blurred (no focusin fires), and the
529
+ // SECOND close would otherwise orphan focus on <body>.
530
+ const el = mount({ modelValue: "a {{ username }} b" });
531
+ await nextTick();
532
+ const edit = editable(el);
533
+ const chip = edit.querySelector<HTMLElement>(".hk-tpl-chip")!;
534
+
535
+ chip.click();
536
+ await nextTick();
537
+ document.body.querySelector<HTMLInputElement>(".hk-tpl-editor-search input")!.focus();
538
+ await nextTick();
539
+ // Close #1 via the panel's Escape path.
540
+ document.body
541
+ .querySelector<HTMLElement>(".hk-popover-panel")!
542
+ .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
543
+ await nextTick();
544
+ // Reopen immediately (inside the leave window — the panel may still
545
+ // be unmounting).
546
+ chip.click();
547
+ await nextTick();
548
+ // Close #2.
549
+ document.body
550
+ .querySelector<HTMLElement>(".hk-popover-panel")!
551
+ .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
552
+ await nextTick();
553
+ await new Promise((r) => setTimeout(r, 40));
554
+ await nextTick();
555
+ expect(document.activeElement).toBe(edit);
556
+ });
557
+
558
+ it("re-targeting another chip while open keeps exactly one scrollbar", async () => {
559
+ // Whatever interaction interleaving the user produces (here: a
560
+ // second chip click while the editor is open — the popover's
561
+ // outside-click shield closes and the click handler reopens), the
562
+ // rows host must never end up with more than ONE rail pair. Pins
563
+ // the leak invariant, not any particular detach call site.
564
+ const el = mount({ modelValue: "a {{ username }} b {{ md5_email }}" });
565
+ await nextTick();
566
+ const edit = editable(el);
567
+ const chips = [...edit.querySelectorAll<HTMLElement>(".hk-tpl-chip")];
568
+ expect(chips).toHaveLength(2);
569
+ chips[0]!.click();
570
+ await nextTick();
571
+ chips[1]!.click();
572
+ await nextTick();
573
+ const hosts = document.body.querySelectorAll(".hk-tpl-editor-scroll");
574
+ expect(hosts).toHaveLength(1);
575
+ expect(hosts[0]!.querySelectorAll(".hk-scrollbar-track")).toHaveLength(1);
576
+ });
577
+
578
+ it("detaches the scrollbar machinery on close (no live observer leak)", async () => {
579
+ // R2 M4: the close-path detach is load-bearing — without it the
580
+ // rows handle's ResizeObserver + scroll listeners stay attached to
581
+ // a viewport that has left the document until the field unmounts
582
+ // (~a bounded leak). Counting LIVE observers (not bare disconnect
583
+ // calls — sibling components have their own ROs) is the precise
584
+ // signal, matching R2's real-browser instrumentation.
585
+ const RealRO = globalThis.ResizeObserver;
586
+ const live = new Set<unknown>();
587
+ class TrackingRO extends RealRO {
588
+ constructor(cb: ResizeObserverCallback) {
589
+ super(cb);
590
+ live.add(this);
591
+ }
592
+ override disconnect(): void {
593
+ live.delete(this);
594
+ super.disconnect();
595
+ }
596
+ }
597
+ (globalThis as unknown as Record<string, unknown>).ResizeObserver = TrackingRO;
598
+ try {
599
+ const el = mount({ modelValue: "a {{ username }} b" });
600
+ await nextTick();
601
+ const base = live.size;
602
+ const edit = editable(el);
603
+ edit.querySelector<HTMLElement>(".hk-tpl-chip")!.click();
604
+ await nextTick();
605
+ expect(live.size, "scrollbar attached an observer").toBeGreaterThan(base);
606
+
607
+ document.body
608
+ .querySelector<HTMLElement>(".hk-popover-panel")!
609
+ .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
610
+ await nextTick();
611
+ await new Promise((r) => setTimeout(r, 30));
612
+ await nextTick();
613
+ expect(live.size, "close detached every observer it attached").toBe(base);
614
+ } finally {
615
+ (globalThis as unknown as Record<string, unknown>).ResizeObserver = RealRO;
616
+ }
617
+ });
618
+
619
+ it("attaches exactly one overlay scrollbar across repeated opens", async () => {
620
+ const el = mount({ modelValue: "a {{ username }} b" });
621
+ await nextTick();
622
+ const edit = editable(el);
623
+ const chip = edit.querySelector<HTMLElement>(".hk-tpl-chip")!;
624
+ for (let round = 0; round < 3; round++) {
625
+ chip.click();
626
+ await nextTick();
627
+ const host = document.body.querySelector(".hk-tpl-editor-scroll")!;
628
+ expect(host.querySelectorAll(".hk-scrollbar-track")).toHaveLength(1);
629
+ document.body
630
+ .querySelector<HTMLElement>(".hk-popover-panel")!
631
+ .dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
632
+ await nextTick();
633
+ await new Promise((r) => setTimeout(r, 20));
634
+ await nextTick();
635
+ }
636
+ });
637
+ });
638
+
639
+ describe("HkTemplateField partially-grouped vocabulary", () => {
640
+ it("renders a heading only where a group starts (documented contract)", async () => {
641
+ const el = mountWithTokens([
642
+ { name: "id", group: "identity" },
643
+ { name: "free_a" }, // ungrouped: continues the identity band
644
+ { name: "free_b" },
645
+ { name: "md5_email", group: "email" },
646
+ { name: "free_c" },
647
+ ]);
648
+ await nextTick();
649
+ await openTrigger(el, "{{ ");
650
+ const headings = Array.from(document.body.querySelectorAll<HTMLElement>(".hk-tpl-group"));
651
+ // Two headings for two group STARTS; the trailing ungrouped tokens
652
+ // stay under the last heading (see the groupLabel prop doc).
653
+ expect(headings.map((h) => h.textContent)).toEqual(["identity", "email"]);
654
+ expect(document.body.querySelectorAll(".hk-tpl-row")).toHaveLength(5);
655
+ });
656
+ });
@@ -2,9 +2,10 @@ import {
2
2
  computed,
3
3
  defineComponent,
4
4
  nextTick,
5
- useId,
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";
@@ -84,6 +87,16 @@ export const HkTemplateField = defineComponent({
84
87
  /** Submit intent on Enter (when no suggestion row is active) —
85
88
  * same contract as HkInput. */
86
89
  submitOnEnter: { type: Function, default: undefined },
90
+ /** Maps a token's `group` to the heading label shown over its run
91
+ * of rows (both the vocabulary panel and the chip editor). Passed
92
+ * through verbatim when omitted — groups are consumer-defined
93
+ * technical keys, so the library has no opinion on their wording.
94
+ *
95
+ * Partially-grouped vocabularies: a token WITHOUT a `group` is not
96
+ * a section of its own — it renders under the previous heading's
97
+ * band (the heading marks where a group STARTS, not where it ends).
98
+ * Give every token a group when the sections must be airtight. */
99
+ groupLabel: { type: Function as PropType<(group: string) => string>, default: undefined },
87
100
  },
88
101
  emits: {
89
102
  "update:modelValue": (_v: string) => true,
@@ -419,6 +432,41 @@ export const HkTemplateField = defineComponent({
419
432
 
420
433
  const editorRows = computed(() => filterTokens(props.tokens, editorQuery.value));
421
434
 
435
+ // The rows list scrolls through the SHARED overlay scrollbar
436
+ // (house chrome — a native overflow bar inside a hikari popup is
437
+ // the family violation this replaces). The scroll host wraps
438
+ // EXACTLY the scrolling rows (rails must not span the form's other
439
+ // bands); sheet mode attaches nothing — the docked panel owns the
440
+ // window's one scrollbar there.
441
+ const { isMobile } = useBreakpoint();
442
+ const editorRowsHostRef = ref<HTMLElement | null>(null);
443
+ const editorRowsRef = ref<HTMLElement | null>(null);
444
+ let editorRowsScrollbar: OverlayScrollbarHandle | null = null;
445
+
446
+ function syncEditorRowsScrollbar(): void {
447
+ editorRowsScrollbar?.update();
448
+ }
449
+
450
+ // The shared scrollbar observes the VIEWPORT box; a vocabulary edit
451
+ // that changes the rows' content height without resizing the viewport
452
+ // would leave the thumb stale until the next scroll — refresh it when
453
+ // the (filtered) rows change.
454
+ watch(editorRows, () => { nextTick(syncEditorRowsScrollbar); });
455
+
456
+ function detachEditorRowsScrollbar(): void {
457
+ editorRowsScrollbar?.detach();
458
+ editorRowsScrollbar = null;
459
+ }
460
+
461
+ function attachEditorRowsScrollbar(): void {
462
+ detachEditorRowsScrollbar();
463
+ if (isMobile.value) return; // sheet: the panel owns the scrollbar
464
+ const viewport = editorRowsRef.value;
465
+ const host = editorRowsHostRef.value;
466
+ if (!viewport || !host) return;
467
+ editorRowsScrollbar = attachOverlayScrollbars(viewport, { axis: "vertical", host });
468
+ }
469
+
422
470
  const editorDef = computed(() => tokenIndex.value.get(editorToken.value));
423
471
 
424
472
  function openChipEditor(chip: HTMLElement): void {
@@ -428,6 +476,10 @@ export const HkTemplateField = defineComponent({
428
476
  // panel's outside-click shield does not fire) trades it for the
429
477
  // editor instead of stacking both.
430
478
  closeSuggestions();
479
+ // A reopen cancels any reclaim still owed by the previous close's
480
+ // leave window — firing it now would steal focus from the freshly
481
+ // opened editor's search input.
482
+ pendingEditorReclaim.value = false;
431
483
  editorChip.value = chip;
432
484
  editorToken.value = chip.dataset.token ?? "";
433
485
  editorQuery.value = "";
@@ -437,6 +489,13 @@ export const HkTemplateField = defineComponent({
437
489
  // popover and the mobile sheet.
438
490
  nextTick(() => {
439
491
  editorSearchWrap.value?.querySelector("input")?.focus();
492
+ // Re-arm the focus latch explicitly: a reopen INSIDE the previous
493
+ // close's leave window focuses an input that (in programmatic
494
+ // flows) never blurred, so no focusin fires and the latch would
495
+ // stay false — the next close would then skip its reclaim and
496
+ // orphan focus on <body> (R1 S2 finding).
497
+ editorHoldsFocus.value = true;
498
+ attachEditorRowsScrollbar();
440
499
  });
441
500
  }
442
501
 
@@ -527,44 +586,49 @@ export const HkTemplateField = defineComponent({
527
586
  editRef.value?.focus();
528
587
  }
529
588
 
589
+ /** True when focus is unowned (body/nowhere) or already back on
590
+ * this field — the only states the editor close may reclaim it
591
+ * from. An outside-click close that focused another control must
592
+ * not have focus yanked back 3ms later (real-browser R1 finding). */
593
+ function editorFocusReclaimable(): boolean {
594
+ const active = document.activeElement;
595
+ return (
596
+ !active ||
597
+ active === document.body ||
598
+ (editRef.value ? active === editRef.value || editRef.value.contains(active) : false)
599
+ );
600
+ }
601
+
602
+ function reclaimEditorFocus(): void {
603
+ if (!editorFocusReclaimable()) return;
604
+ editRef.value?.focus();
605
+ // The chip may have been re-rendered away by the time this runs
606
+ // (a late reclaim) — a detached reference must not steer the
607
+ // caret (offsetAfterChip would return the full length).
608
+ if (editorChip.value?.isConnected) {
609
+ applyCaretOffset(offsetAfterChip(editorChip.value));
610
+ }
611
+ }
612
+
613
+ /** Set when a closing chip editor may still owe the field its
614
+ * focus back (through the leave window); cleared by the popover's
615
+ * settled `closed` edge or the next open. */
616
+ const pendingEditorReclaim = ref(false);
617
+
530
618
  function closeChipEditor(): void {
531
619
  if (!editorOpen.value) return;
532
620
  editorOpen.value = false;
621
+ detachEditorRowsScrollbar();
533
622
  if (editorHoldsFocus.value) {
534
623
  editorHoldsFocus.value = false;
535
- /** True when focus is unowned (body/nowhere) or already back
536
- * on this field — the only states we may reclaim it from. */
537
- const reclaimable = () => {
538
- const active = document.activeElement;
539
- return (
540
- !active ||
541
- active === document.body ||
542
- (editRef.value ? active === editRef.value || editRef.value.contains(active) : false)
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);
624
+ nextTick(reclaimEditorFocus);
625
+ // An Escape close keeps the search input holding focus THROUGH
626
+ // the closing animation, so the probe above correctly declines;
627
+ // when the panel finishes unmounting, focus falls to <body>
628
+ // with nobody to reclaim it. The popover's `closed` event (the
629
+ // settled leave edge) re-runs the same ownership probe exactly
630
+ // then — no timer race with a long or short leave animation.
631
+ pendingEditorReclaim.value = true;
568
632
  }
569
633
  }
570
634
 
@@ -582,6 +646,10 @@ export const HkTemplateField = defineComponent({
582
646
  renderValue(props.modelValue);
583
647
  });
584
648
 
649
+ onBeforeUnmount(() => {
650
+ detachEditorRowsScrollbar();
651
+ });
652
+
585
653
  // ── Editable event wiring ───────────────────────────────────────
586
654
 
587
655
  function onInput(): void {
@@ -807,6 +875,37 @@ export const HkTemplateField = defineComponent({
807
875
  };
808
876
  }
809
877
 
878
+ /** Rows with non-interactive group headings: a heading lands
879
+ * wherever the (filtered) vocabulary's group changes, so a family
880
+ * whose every member was filtered out never shows a stray header.
881
+ * Vocabulary order is the contract — consumers list each group's
882
+ * tokens contiguously. */
883
+ function renderGroupedRows(
884
+ defs: readonly HkTemplateTokenDef[],
885
+ renderRow: (def: HkTemplateTokenDef, index: number) => unknown,
886
+ ): unknown[] {
887
+ const out: unknown[] = [];
888
+ let lastGroup: string | undefined;
889
+ for (let i = 0; i < defs.length; i++) {
890
+ const def = defs[i]!;
891
+ if (def.group !== undefined) {
892
+ if (def.group !== lastGroup) {
893
+ out.push(
894
+ <div class="hk-tpl-group" role="presentation">
895
+ {props.groupLabel ? props.groupLabel(def.group) : def.group}
896
+ </div>,
897
+ );
898
+ }
899
+ lastGroup = def.group;
900
+ }
901
+ // Tokens without a group simply continue under the previous
902
+ // heading's visual section — the documented contract for
903
+ // partially-grouped vocabularies (see the prop doc).
904
+ out.push(renderRow(def, i));
905
+ }
906
+ return out;
907
+ }
908
+
810
909
  const renderTokenRow = (
811
910
  def: HkTemplateTokenDef,
812
911
  active: boolean,
@@ -883,7 +982,7 @@ export const HkTemplateField = defineComponent({
883
982
  >
884
983
  {filteredTokens.value.length > 0 ? (
885
984
  <div class="hk-tpl-rows" role="listbox">
886
- {filteredTokens.value.map((def, i) =>
985
+ {renderGroupedRows(filteredTokens.value, (def, i) =>
887
986
  renderTokenRow(
888
987
  def,
889
988
  i === Math.min(suggestActive.value, filteredTokens.value.length - 1),
@@ -906,6 +1005,16 @@ export const HkTemplateField = defineComponent({
906
1005
  onUpdate:modelValue={(v: boolean) => {
907
1006
  if (!v) closeChipEditor();
908
1007
  }}
1008
+ onClosed={() => {
1009
+ if (!pendingEditorReclaim.value) return;
1010
+ pendingEditorReclaim.value = false;
1011
+ // HkPopover emits this from a nextTick AFTER the patch that
1012
+ // removed the panel (its own doc contract, verified in a
1013
+ // real browser: the handler sees no panel and focus already
1014
+ // fallen to <body>) — so the ownership probe can run here
1015
+ // directly.
1016
+ reclaimEditorFocus();
1017
+ }}
909
1018
  anchorRef={editorChip.value}
910
1019
  sheetOnMobile
911
1020
  placement="bottom-start"
@@ -937,19 +1046,24 @@ export const HkTemplateField = defineComponent({
937
1046
  >
938
1047
  <HkInput
939
1048
  modelValue={editorQuery.value}
940
- onUpdate:modelValue={(v: string) => { editorQuery.value = v; }}
1049
+ onUpdate:modelValue={(v: string) => {
1050
+ editorQuery.value = v;
1051
+ nextTick(syncEditorRowsScrollbar);
1052
+ }}
941
1053
  placeholder={t("hikari::templateField.chipSearch", "Search placeholders")}
942
1054
  />
943
1055
  </div>
944
- <div class="hk-tpl-editor-rows" role="listbox">
945
- {editorRows.value.map((def) =>
946
- renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
947
- )}
948
- {editorRows.value.length === 0 && (
949
- <p class="hk-tpl-rows-empty">
950
- {t("hikari::templateField.suggestEmpty", "No matching placeholders")}
951
- </p>
952
- )}
1056
+ <div ref={editorRowsHostRef} class="hk-tpl-editor-scroll">
1057
+ <div ref={editorRowsRef} class="hk-tpl-editor-rows" role="listbox">
1058
+ {renderGroupedRows(editorRows.value, (def) =>
1059
+ renderTokenRow(def, def.name === editorToken.value, () => applyChipToken(def.name)),
1060
+ )}
1061
+ {editorRows.value.length === 0 && (
1062
+ <p class="hk-tpl-rows-empty">
1063
+ {t("hikari::templateField.suggestEmpty", "No matching placeholders")}
1064
+ </p>
1065
+ )}
1066
+ </div>
953
1067
  </div>
954
1068
  <div class="hk-tpl-editor-footer">
955
1069
  <HButton