@celestia-island/hikari 0.55.155 → 0.55.156

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.155",
3
+ "version": "0.55.156",
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,17 @@
1
+ .hk-infinite-tail-sentinel {
2
+ // The sentinel is a zero-visibility geometry probe: it must occupy a
3
+ // stable sliver of layout (height 1px) so the IntersectionObserver
4
+ // has a rect to intersect, while staying invisible in every theme.
5
+ display: block;
6
+ width: 100%;
7
+ height: 1px;
8
+ margin: 0;
9
+ pointer-events: none;
10
+ }
11
+
12
+ .hk-infinite-tail-loading {
13
+ // The skeleton tail rides the list's own rhythm — padding only, no
14
+ // border/background of its own, so the placeholders read as rows of
15
+ // the list that are about to exist rather than a separate block.
16
+ padding: var(--space-8, 0.5rem) 0 var(--space-12, 0.75rem);
17
+ }
@@ -0,0 +1,155 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, ref, type App } from "vue";
3
+
4
+ import { HkInfiniteTail } from "./HkInfiniteTail";
5
+
6
+ type IOMock = {
7
+ observe: ReturnType<typeof vi.fn>;
8
+ disconnect: ReturnType<typeof vi.fn>;
9
+ callback: IntersectionObserverCallback;
10
+ };
11
+
12
+ const apps: App[] = [];
13
+ const containers: HTMLElement[] = [];
14
+ let originalIO: (typeof globalThis)["IntersectionObserver"];
15
+ let originalRect: typeof HTMLElement.prototype.getBoundingClientRect;
16
+ let ioInstances: IOMock[] = [];
17
+
18
+ /** A capturing IntersectionObserver stub: records every instance so a
19
+ * test can synthesize intersections against the real sentinel. */
20
+ class IntersectionObserverStub {
21
+ constructor(callback: IntersectionObserverCallback) {
22
+ const mock: IOMock = {
23
+ observe: vi.fn(),
24
+ disconnect: vi.fn(),
25
+ callback,
26
+ };
27
+ ioInstances.push(mock);
28
+ return (mock as unknown) as IntersectionObserverStub;
29
+ }
30
+ observe(): void {}
31
+ disconnect(): void {}
32
+ }
33
+
34
+ function mount(node: ReturnType<typeof h>) {
35
+ const container = document.createElement("div");
36
+ document.body.appendChild(container);
37
+ const app = createApp({ render: () => node });
38
+ app.mount(container);
39
+ apps.push(app);
40
+ containers.push(container);
41
+ return container;
42
+ }
43
+
44
+ function rect(top: number, height: number): DOMRect {
45
+ return {
46
+ x: 0, y: top, top, left: 0, right: 100, bottom: top + height,
47
+ width: 100, height, toJSON: () => ({}),
48
+ } as DOMRect;
49
+ }
50
+
51
+ beforeEach(() => {
52
+ originalIO = (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver as never;
53
+ ioInstances = [];
54
+ (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = IntersectionObserverStub;
55
+ originalRect = HTMLElement.prototype.getBoundingClientRect;
56
+ });
57
+
58
+ afterEach(() => {
59
+ for (const app of apps.splice(0)) app.unmount();
60
+ for (const el of containers.splice(0)) el.remove();
61
+ (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver = originalIO;
62
+ HTMLElement.prototype.getBoundingClientRect = originalRect;
63
+ });
64
+
65
+ describe("HkInfiniteTail", () => {
66
+ it("loading renders the animated skeleton placeholders", () => {
67
+ const c = mount(h(HkInfiniteTail, { state: "loading", skeletonCount: 4 }));
68
+ const skeletons = c.querySelectorAll(".hk-skeleton");
69
+ expect(skeletons.length).toBe(4);
70
+ expect(c.querySelector('[role="status"]')).not.toBeNull();
71
+ });
72
+
73
+ it("end renders the reached-the-end note with the progress counter", () => {
74
+ const c = mount(h(HkInfiniteTail, { state: "end", shown: 19, total: 19 }));
75
+ expect(c.querySelector(".hk-load-more")!.getAttribute("data-state")).toBe("end");
76
+ expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("19 / 19");
77
+ expect(c.textContent).toContain("You've reached the end");
78
+ });
79
+
80
+ it("idle + auto renders the invisible sentinel and observes it", async () => {
81
+ const c = mount(h(HkInfiniteTail, { state: "idle" }));
82
+ const sentinel = c.querySelector(".hk-infinite-tail-sentinel") as HTMLElement;
83
+ expect(sentinel).not.toBeNull();
84
+ // The ref watcher attaches the observer on the post-flush pass.
85
+ await new Promise((r) => setTimeout(r, 10));
86
+ expect(ioInstances.length).toBe(1);
87
+ expect(ioInstances[0].observe).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("every intersecting observer callback emits (pacing is the machine's)", async () => {
91
+ const fired: number[] = [];
92
+ mount(h(HkInfiniteTail, { state: "idle", onLoadMore: () => fired.push(1) }));
93
+ await new Promise((r) => setTimeout(r, 10));
94
+ const { callback } = ioInstances[0];
95
+ const entry = { isIntersecting: true, target: {} } as unknown as IntersectionObserverEntry;
96
+ callback([entry], (null as unknown) as IntersectionObserver);
97
+ expect(fired.length).toBe(1);
98
+ // A second intersection (geometry changed) emits again — the tail
99
+ // never throttles; the state machine's busy guard paces the loop.
100
+ callback([entry], (null as unknown) as IntersectionObserver);
101
+ expect(fired.length).toBe(2);
102
+ });
103
+
104
+ it("loading does not emit and neither does end", () => {
105
+ const fired: number[] = [];
106
+ mount(h(HkInfiniteTail, { state: "loading", onLoadMore: () => fired.push(1) }));
107
+ mount(h(HkInfiniteTail, { state: "end", onLoadMore: () => fired.push(1) }));
108
+ expect(ioInstances.length).toBe(0); // neither state renders a sentinel
109
+ expect(fired.length).toBe(0);
110
+ });
111
+
112
+ it("re-entering idle re-checks the rect and keeps filling (auto-fill)", async () => {
113
+ const fired: number[] = [];
114
+ // Sentinel sits inside the viewport → the rect check must fire.
115
+ HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) {
116
+ if (this.classList.contains("hk-infinite-tail-sentinel")) return rect(200, 1);
117
+ return originalRect.call(this);
118
+ };
119
+ // The transition the machine produces when a page lands while the
120
+ // tail is still on screen: loading → idle on the SAME component
121
+ // instance, with no observer event to carry it — the rect re-check
122
+ // is what keeps the fill going.
123
+ const state = ref<"loading" | "idle">("loading");
124
+ const Host = defineComponent({
125
+ setup() {
126
+ return () => h(HkInfiniteTail, { state: state.value, onLoadMore: () => fired.push(1) });
127
+ },
128
+ });
129
+ mount(h(Host));
130
+ expect(fired.length).toBe(0);
131
+ state.value = "idle";
132
+ await new Promise((r) => setTimeout(r, 30));
133
+ expect(fired.length).toBeGreaterThanOrEqual(1);
134
+ });
135
+
136
+ it("manual mode loading morphs the button instead of skeleton rows", () => {
137
+ const c = mount(h(HkInfiniteTail, { state: "loading", auto: false }));
138
+ // No skeleton placeholders in manual mode — the button morphs.
139
+ expect(c.querySelectorAll(".hk-skeleton").length).toBe(0);
140
+ const note = c.querySelector(".hk-load-more")!;
141
+ expect(note.getAttribute("data-state")).toBe("loading");
142
+ expect(c.querySelector("button.hk-btn")!.textContent).toContain("Loading");
143
+ });
144
+
145
+ it("manual mode renders the load-more button instead of the sentinel", () => {
146
+ const fired: number[] = [];
147
+ const c = mount(h(HkInfiniteTail, { state: "idle", auto: false, onLoadMore: () => fired.push(1) }));
148
+ expect(c.querySelector(".hk-infinite-tail-sentinel")).toBeNull();
149
+ const button = c.querySelector("button.hk-btn") as HTMLButtonElement;
150
+ expect(button).not.toBeNull();
151
+ button.click();
152
+ expect(fired.length).toBe(1);
153
+ expect(ioInstances.length).toBe(0);
154
+ });
155
+ });
@@ -0,0 +1,222 @@
1
+ import { defineComponent, nextTick, onScopeDispose, ref, watch, type PropType } from "vue";
2
+
3
+ import HSkeletonList from "./HkSkeletonList";
4
+ import HkLoadMore from "./HkLoadMore";
5
+ import "./HkInfiniteTail.scss";
6
+
7
+ /**
8
+ * HkInfiniteTail — the DOM half of the infinite-list scaffold, mounted
9
+ * directly after the list body (inside whatever scroll viewport the
10
+ * host already uses; it never creates one). Paired with
11
+ * `useInfinitePages`, the page state machine.
12
+ *
13
+ * State derivation (bind EXACTLY this — a first-page load must read as
14
+ * `"loading"` too, or a short first page never auto-fills):
15
+ *
16
+ * state = exhausted ? "end" : (loading || loadingMore) ? "loading" : "idle"
17
+ *
18
+ * - `"loading"` — a page is in flight: the animated skeleton
19
+ * placeholder rows (HkSkeletonList) occupy the tail, so the list
20
+ * reads as "more is arriving", not "the page ended". In manual mode
21
+ * the load-more button morphs to its in-place spinner instead
22
+ * (HkLoadMore's own loading contract).
23
+ * - `"end"` — the machine is exhausted: the muted end note (and the
24
+ * shown/total progress counter when the host passes them).
25
+ * - `"idle"` — more exists, nothing in flight. With `auto` (default)
26
+ * the invisible sentinel watches the viewport and emits `loadMore`
27
+ * as it approaches; with `auto={false}` the explicit load-more
28
+ * button (HkLoadMore) renders instead. NOTE: with `auto`, a tail
29
+ * page failure retries in place every time the sentinel re-enters
30
+ * view (the machine's error guard paces it to one attempt per
31
+ * emission, no backoff) — hosts that want backoff map the error
32
+ * state away from `"idle"` themselves.
33
+ *
34
+ * The sentinel observes the BROWSER viewport (IntersectionObserver,
35
+ * `rootMargin` lookahead), so it works inside any nested scroll
36
+ * container without knowing which ancestor scrolls. Intersection
37
+ * alone misses the "page landed but the tail is still on screen"
38
+ * case (the observer does not re-fire without a geometry change), so
39
+ * every `idle` entry re-checks the sentinel's rect on the next frame
40
+ * and keeps filling until the tail actually leaves the viewport — the
41
+ * auto-fill contract for short first screens.
42
+ */
43
+ export const HkInfiniteTail = defineComponent({
44
+ name: "HkInfiniteTail",
45
+ props: {
46
+ /** `loading` — fetching a later page (skeleton); `end` — exhausted
47
+ * (end note); `idle` — more available, awaiting the sentinel. */
48
+ state: {
49
+ type: String as PropType<"idle" | "loading" | "end">,
50
+ default: "idle" as const,
51
+ },
52
+ /** Auto-load on approach (default). `false` renders the explicit
53
+ * load-more button instead of the invisible sentinel. */
54
+ auto: { type: Boolean, default: true },
55
+ /** Delivered item count (with `total`, the progress counter). */
56
+ shown: { type: Number, default: undefined },
57
+ /** Match count behind the windowed list. */
58
+ total: { type: Number, default: undefined },
59
+ /** Skeleton placeholder rows while a later page is in flight. */
60
+ skeletonCount: { type: Number, default: 3 },
61
+ /** Row height of the skeleton placeholders (match the host's row
62
+ * height so the tail does not jump when real rows land). */
63
+ skeletonItemHeight: { type: String, default: "40px" },
64
+ /** Viewport lookahead in px before the tail enters view (default
65
+ * 480 — about half a screen of runway). Live: a change re-observes
66
+ * the sentinel with the new margin (the rect path reads it live
67
+ * either way). */
68
+ lookahead: { type: Number, default: 480 },
69
+ /** Diagnostic hook: rendered as `data-label` on the (aria-hidden)
70
+ * sentinel — for tests and DOM inspection, NOT an a11y name (the
71
+ * sentinel is invisible geometry; the visible states carry their
72
+ * own accessible text). */
73
+ label: { type: String, default: undefined },
74
+ },
75
+ emits: {
76
+ loadMore: () => true,
77
+ },
78
+ setup(props, { emit }) {
79
+ const sentinelRef = ref<HTMLElement | null>(null);
80
+ let io: IntersectionObserver | null = null;
81
+ let rafHandle: number | null = null;
82
+ let stopped = false;
83
+
84
+ function maybeLoad(): void {
85
+ if (stopped) return;
86
+ if (props.state !== "idle") return;
87
+ emit("loadMore");
88
+ }
89
+
90
+ function sentinelInView(): boolean {
91
+ const el = sentinelRef.value;
92
+ if (!el) return false;
93
+ const rect = el.getBoundingClientRect();
94
+ if (rect.height === 0 && rect.width === 0) return false;
95
+ // Mirror the observer's lookahead: the zone extends `lookahead`
96
+ // px below the viewport bottom (and above the top, so a fast
97
+ // upward fling back into the zone also counts).
98
+ const vh = window.innerHeight || 0;
99
+ return rect.top <= vh + props.lookahead && rect.bottom >= -props.lookahead;
100
+ }
101
+
102
+ function scheduleRectCheck(): void {
103
+ if (stopped || rafHandle != null) return;
104
+ rafHandle = requestAnimationFrame(() => {
105
+ rafHandle = null;
106
+ if (stopped) return;
107
+ if (sentinelInView()) maybeLoad();
108
+ });
109
+ }
110
+
111
+ function observe(): void {
112
+ unobserve();
113
+ const el = sentinelRef.value;
114
+ if (!el || typeof IntersectionObserver === "undefined") return;
115
+ io = new IntersectionObserver(
116
+ (entries) => {
117
+ for (const entry of entries) {
118
+ if (entry.isIntersecting) maybeLoad();
119
+ }
120
+ },
121
+ { rootMargin: `${props.lookahead}px 0px ${props.lookahead}px 0px` },
122
+ );
123
+ io.observe(el);
124
+ }
125
+
126
+ function unobserve(): void {
127
+ io?.disconnect();
128
+ io = null;
129
+ }
130
+
131
+ // Sentinel mount/swap: attach the observer.
132
+ watch(sentinelRef, (el) => {
133
+ if (el && !stopped) observe();
134
+ else unobserve();
135
+ }, { flush: "post" });
136
+
137
+ // A live `lookahead` change re-observes with the new margin (the
138
+ // margin is baked into the observer at observe time; the rect path
139
+ // above already reads it live).
140
+ watch(
141
+ () => props.lookahead,
142
+ () => {
143
+ if (sentinelRef.value && !stopped) observe();
144
+ },
145
+ );
146
+
147
+ // Every transition INTO idle re-checks the geometry on the next
148
+ // frame: a page that landed while the sentinel stayed on screen
149
+ // produced no intersection change, but the list still needs its
150
+ // next window (auto-fill).
151
+ watch(
152
+ () => props.state,
153
+ (state, prev) => {
154
+ if (state === "idle" && prev !== "idle") {
155
+ void nextTick(scheduleRectCheck);
156
+ }
157
+ },
158
+ );
159
+
160
+ onScopeDispose(() => {
161
+ stopped = true;
162
+ unobserve();
163
+ if (rafHandle != null) cancelAnimationFrame(rafHandle);
164
+ });
165
+
166
+ return () => {
167
+ // Manual mode never renders the sentinel; the button is the
168
+ // affordance. The end note renders in both modes.
169
+ if (props.state === "end") {
170
+ return (
171
+ <HkLoadMore
172
+ state="end"
173
+ shown={props.shown}
174
+ total={props.total}
175
+ />
176
+ );
177
+ }
178
+ if (props.state === "loading") {
179
+ // Manual mode: the button morphs to its in-place spinner
180
+ // (HkLoadMore's advertised loading contract) instead of being
181
+ // replaced by skeletons — the affordance never disappears.
182
+ if (!props.auto) {
183
+ return (
184
+ <HkLoadMore
185
+ state="loading"
186
+ shown={props.shown}
187
+ total={props.total}
188
+ />
189
+ );
190
+ }
191
+ return (
192
+ <div class="hk-infinite-tail-loading" role="status" aria-live="polite">
193
+ <HSkeletonList
194
+ count={props.skeletonCount}
195
+ itemHeight={props.skeletonItemHeight}
196
+ />
197
+ </div>
198
+ );
199
+ }
200
+ if (!props.auto) {
201
+ return (
202
+ <HkLoadMore
203
+ state="idle"
204
+ shown={props.shown}
205
+ total={props.total}
206
+ onLoadMore={() => maybeLoad()}
207
+ />
208
+ );
209
+ }
210
+ return (
211
+ <div
212
+ ref={sentinelRef}
213
+ class="hk-infinite-tail-sentinel"
214
+ aria-hidden="true"
215
+ data-label={props.label}
216
+ />
217
+ );
218
+ };
219
+ },
220
+ });
221
+
222
+ export default HkInfiniteTail;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * HkSearchTrigger chrome: the header icon button, its active-keyword
3
+ * dot and the panel body. Deliberately the SAME grammar as
4
+ * HkFilterTrigger.scss — filter and search are the two header
5
+ * affordances of every list page and must read as one family.
6
+ */
7
+
8
+ .hk-search-trigger {
9
+ position: relative;
10
+ display: inline-flex;
11
+ align-items: center;
12
+ }
13
+
14
+ /* Active-keyword marker: the filter trigger's dot, verbatim — "this
15
+ * page is searched" reads exactly like "this page is filtered". */
16
+ .hk-search-trigger-dot {
17
+ position: absolute;
18
+ top: 1px;
19
+ right: 1px;
20
+ width: 6px;
21
+ height: 6px;
22
+ border-radius: 50%;
23
+ background: rgb(var(--color-primary));
24
+ pointer-events: none;
25
+ }
26
+
27
+ .hk-search-trigger-body {
28
+ /* One field, bounded like the filter panel: wide enough for a
29
+ * phrase, narrow enough to stay anchored beside its trigger. */
30
+ min-width: 240px;
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--space-8, 0.5rem);
34
+ }
35
+
36
+ .hk-search-trigger-footer {
37
+ display: flex;
38
+ justify-content: flex-end;
39
+ padding-top: var(--space-8, 0.5rem);
40
+ border-top: 1px solid rgb(var(--color-border) / 10%);
41
+ margin-top: var(--space-8, 0.5rem);
42
+ }
@@ -0,0 +1,158 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, h, ref, type App } from "vue";
3
+
4
+ import HkSearchTrigger from "./HkSearchTrigger";
5
+
6
+ const apps: App[] = [];
7
+ const containers: HTMLElement[] = [];
8
+
9
+ function mount(node: ReturnType<typeof h>) {
10
+ const container = document.createElement("div");
11
+ document.body.appendChild(container);
12
+ const app = createApp({ render: () => node });
13
+ app.mount(container);
14
+ apps.push(app);
15
+ containers.push(container);
16
+ return container;
17
+ }
18
+
19
+ afterEach(() => {
20
+ for (const app of apps.splice(0)) app.unmount();
21
+ for (const el of containers.splice(0)) el.remove();
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function triggerButton(container: HTMLElement): HTMLButtonElement {
26
+ const btn = container.querySelector<HTMLButtonElement>("button[aria-haspopup='dialog']");
27
+ expect(btn).not.toBeNull();
28
+ return btn!;
29
+ }
30
+
31
+ function searchField(): HTMLInputElement | null {
32
+ return document.querySelector<HTMLInputElement>(".hk-search-input-field");
33
+ }
34
+
35
+ /** A host that actually BINDS the keyword two-way — the trigger's
36
+ * commit guard compares against `modelValue`, so a static prop would
37
+ * swallow every commit after the first (exactly the bug a v-model host
38
+ * never sees). */
39
+ function mountBound(props: Record<string, unknown> = {}) {
40
+ const commits: string[] = [];
41
+ const state = ref("");
42
+ const Host = {
43
+ setup() {
44
+ return () =>
45
+ h(HkSearchTrigger, {
46
+ ...props,
47
+ modelValue: state.value,
48
+ "onUpdate:modelValue": (v: string) => {
49
+ state.value = v;
50
+ commits.push(v);
51
+ },
52
+ });
53
+ },
54
+ };
55
+ const c = mount(h(Host as never));
56
+ return { c, commits, state };
57
+ }
58
+
59
+ describe("HkSearchTrigger", () => {
60
+ it("renders the header-chrome trigger with an accessible name", () => {
61
+ const c = mount(h(HkSearchTrigger, { modelValue: "" }));
62
+ const btn = triggerButton(c);
63
+ expect(btn.getAttribute("aria-label")).toBe("Search");
64
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
65
+ // No keyword → no active dot.
66
+ expect(c.querySelector(".hk-search-trigger-dot")).toBeNull();
67
+ });
68
+
69
+ it("an active keyword shows the dot", () => {
70
+ const c = mount(h(HkSearchTrigger, { modelValue: "stale" }));
71
+ expect(c.querySelector(".hk-search-trigger-dot")).not.toBeNull();
72
+ });
73
+
74
+ it("click opens the panel with the input focused", async () => {
75
+ const c = mount(h(HkSearchTrigger, { modelValue: "" }));
76
+ triggerButton(c).click();
77
+ await new Promise((r) => setTimeout(r, 20));
78
+ const input = searchField();
79
+ expect(input).not.toBeNull();
80
+ // The panel input receives focus on open.
81
+ expect(document.activeElement).toBe(input);
82
+ });
83
+
84
+ it("typing commits debounced; clearing commits immediately", async () => {
85
+ vi.useFakeTimers();
86
+ try {
87
+ const { c, commits } = mountBound({ debounce: 200 });
88
+ triggerButton(c).click();
89
+ await vi.advanceTimersByTimeAsync(10);
90
+ const input = searchField()!;
91
+ input.value = "al";
92
+ input.dispatchEvent(new Event("input"));
93
+ await vi.advanceTimersByTimeAsync(50);
94
+ expect(commits).toEqual([]);
95
+ await vi.advanceTimersByTimeAsync(200);
96
+ expect(commits).toEqual(["al"]);
97
+
98
+ // Clearing is immediate — no debounced dot-lag.
99
+ input.value = "";
100
+ input.dispatchEvent(new Event("input"));
101
+ expect(commits).toEqual(["al", ""]);
102
+ } finally {
103
+ vi.useRealTimers();
104
+ }
105
+ });
106
+
107
+ it("drafts shorter than minLength never commit (panel edits are free)", async () => {
108
+ vi.useFakeTimers();
109
+ try {
110
+ const { c, commits } = mountBound({ minLength: 3, debounce: 50 });
111
+ triggerButton(c).click();
112
+ await vi.advanceTimersByTimeAsync(10);
113
+ const input = searchField()!;
114
+ input.value = "ab";
115
+ input.dispatchEvent(new Event("input"));
116
+ await vi.advanceTimersByTimeAsync(120);
117
+ expect(commits).toEqual([]);
118
+ } finally {
119
+ vi.useRealTimers();
120
+ }
121
+ });
122
+
123
+ it("Enter commits immediately, skipping the debounce", async () => {
124
+ vi.useFakeTimers();
125
+ try {
126
+ const { c, commits } = mountBound({ debounce: 5000 });
127
+ triggerButton(c).click();
128
+ await vi.advanceTimersByTimeAsync(10);
129
+ const input = searchField()!;
130
+ input.value = "ledger";
131
+ input.dispatchEvent(new Event("input"));
132
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
133
+ expect(commits).toEqual(["ledger"]);
134
+ } finally {
135
+ vi.useRealTimers();
136
+ }
137
+ });
138
+
139
+ it("a pending debounced commit lands even after the panel closes", async () => {
140
+ vi.useFakeTimers();
141
+ try {
142
+ const { c, commits } = mountBound({ debounce: 30_000 });
143
+ triggerButton(c).click();
144
+ await vi.advanceTimersByTimeAsync(10);
145
+ const input = searchField()!;
146
+ input.value = "gpu";
147
+ input.dispatchEvent(new Event("input"));
148
+ await vi.advanceTimersByTimeAsync(50);
149
+ expect(commits).toEqual([]);
150
+ // Close with the debounce still pending — the trigger flushes the
151
+ // draft (the popover tears the input, and its timer, down).
152
+ triggerButton(c).click();
153
+ expect(commits).toEqual(["gpu"]);
154
+ } finally {
155
+ vi.useRealTimers();
156
+ }
157
+ });
158
+ });