@celestia-island/hikari 0.55.155 → 0.55.157
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/animation/registerAnimations.ts +0 -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/HkMarkdownRenderer.scss +1 -1
- package/src/components/HkPopover.scss +1 -1
- package/src/components/HkProgressBar.scss +1 -1
- package/src/components/HkSearchTrigger.scss +42 -0
- package/src/components/HkSearchTrigger.test.tsx +158 -0
- package/src/components/HkSearchTrigger.tsx +159 -0
- package/src/components/HkStatCard.tsx +1 -1
- package/src/components/HkTag.scss +1 -1
- package/src/components/HkTimeline.scss +1 -1
- 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 +11 -4
- package/src/styles/theme/channels.scss +1 -1
- package/src/tokens.scss +1 -1
- package/src/utils/format.ts +0 -4
- package/src/utils/powNonce.ts +1 -1
package/package.json
CHANGED
|
@@ -108,7 +108,6 @@ registerCssAnimation("hk-picker-pane-back-out");
|
|
|
108
108
|
// once; keyframes ride the component's inline <style>).
|
|
109
109
|
registerCssAnimation("hk-flip-out");
|
|
110
110
|
registerCssAnimation("hk-flip-in");
|
|
111
|
-
// components/HkVoiceInputPopup.scss — voice waveform bounce (loops
|
|
112
111
|
// forever while listening; upstreamed from chest's plana-legacy).
|
|
113
112
|
// components/HkPlaceholderMarquee.scss — overflowing placeholder sweep
|
|
114
113
|
// (loops forever; pure CSS, loop geometry on inline custom properties).
|
|
@@ -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;
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/* Rendered GFM links are real anchors — keyboard-focusable, so they need
|
|
140
|
-
the house :focus-visible ring (HkFab
|
|
140
|
+
the house :focus-visible ring (HkFab house pattern). Ring uses
|
|
141
141
|
the same contract var as the link ink so it matches in every theme. */
|
|
142
142
|
.hk-markdown a:focus-visible {
|
|
143
143
|
outline: 2px solid var(--hi-color-primary, #7aa2f7);
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
* long-press selected it; every other window in the app carries an ✕).
|
|
206
206
|
* The title aligns to the CONTENT edge: the leading inset defaults to
|
|
207
207
|
* --space-12, matching the padded content blocks popover sheets host
|
|
208
|
-
* (e.g.
|
|
208
|
+
* (e.g. the house 0.75rem popover body). Hosts tune it via
|
|
209
209
|
* --hk-sheet-title-inset; the trailing inset hugs the close button. */
|
|
210
210
|
.hk-popover-sheet-header {
|
|
211
211
|
flex-shrink: 0;
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// Width is a size animation — progress steps snap instead of animating
|
|
138
|
-
// (house guard:
|
|
138
|
+
// (house guard: the family bar-fill pattern).
|
|
139
139
|
.hk-progress-bar-fill,
|
|
140
140
|
.hk-progress-bar-secondary,
|
|
141
141
|
.hk-progress-bar-seg {
|
|
@@ -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
|
+
});
|