@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 +1 -1
- package/src/components/HkInfiniteTail.scss +17 -0
- package/src/components/HkInfiniteTail.test.tsx +155 -0
- package/src/components/HkInfiniteTail.tsx +222 -0
- package/src/components/HkSearchTrigger.scss +42 -0
- package/src/components/HkSearchTrigger.test.tsx +158 -0
- package/src/components/HkSearchTrigger.tsx +159 -0
- package/src/composables/useInfinitePages.test.ts +271 -0
- package/src/composables/useInfinitePages.ts +290 -0
- package/src/i18n/locales/ar/components.json +4 -1
- package/src/i18n/locales/de/components.json +4 -1
- package/src/i18n/locales/en/components.json +4 -1
- package/src/i18n/locales/es/components.json +4 -1
- package/src/i18n/locales/fr/components.json +4 -1
- package/src/i18n/locales/ja/components.json +4 -1
- package/src/i18n/locales/ko/components.json +4 -1
- package/src/i18n/locales/pt/components.json +4 -1
- package/src/i18n/locales/ru/components.json +4 -1
- package/src/i18n/locales/zh-Hans/components.json +4 -1
- package/src/i18n/locales/zh-Hant/components.json +4 -1
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -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
|
+
});
|