@celestia-island/hikari 0.41.9 → 0.42.1
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/DemoApp.tsx +35 -0
- package/src/components/HkLoadMore.scss +53 -0
- package/src/components/HkLoadMore.test.tsx +65 -0
- package/src/components/HkLoadMore.tsx +72 -0
- package/src/components/HkScrollContainer.test.ts +44 -1
- package/src/components/HkScrollContainer.tsx +63 -2
- package/src/components/HkTag.test.tsx +76 -0
- package/src/components/HkTag.tsx +8 -0
- package/src/components/HkTagInput.scss +307 -0
- package/src/components/HkTagInput.singleScroll.contract.test.ts +73 -0
- package/src/components/HkTagInput.test.tsx +1122 -0
- package/src/components/HkTagInput.tsx +761 -0
- package/src/composables/useApproachEnd.test.ts +261 -0
- package/src/composables/useApproachEnd.ts +169 -0
- package/src/i18n/locales/ar/components.json +9 -1
- package/src/i18n/locales/de/components.json +9 -1
- package/src/i18n/locales/en/components.json +9 -1
- package/src/i18n/locales/es/components.json +9 -1
- package/src/i18n/locales/fr/components.json +9 -1
- package/src/i18n/locales/ja/components.json +9 -1
- package/src/i18n/locales/ko/components.json +9 -1
- package/src/i18n/locales/pt/components.json +9 -1
- package/src/i18n/locales/ru/components.json +9 -1
- package/src/i18n/locales/zh-Hans/components.json +9 -1
- package/src/i18n/locales/zh-Hant/components.json +9 -1
- package/src/index.ts +4 -0
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
HSkeleton, HSkeletonList, HAvatar, HKbd, HDivider,
|
|
17
17
|
HAlert, HEmptyState, HExpansionPanel,
|
|
18
18
|
HTabs, HCard, HTable, HTimeline,
|
|
19
|
+
HScrollContainer, HLoadMore,
|
|
19
20
|
HMediaSlider, HMediaPlayer, HImageViewer,
|
|
20
21
|
HImagePreview,
|
|
21
22
|
HZoomToolbar, HMinimap, HTrendChart,
|
|
@@ -197,6 +198,24 @@ export default defineComponent({
|
|
|
197
198
|
syncZoom();
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
// HLoadMore + HScrollContainer approachEnd — the canonical dynamic
|
|
202
|
+
// loading pair: the container emits `approachEnd` when the content
|
|
203
|
+
// end nears (including the initial under-filled pass), the demo
|
|
204
|
+
// "fetches" a page, and the indicator morphs button → spinner → end.
|
|
205
|
+
const pagedRows = ref(Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`));
|
|
206
|
+
const pagedState = ref<"idle" | "loading" | "end">("idle");
|
|
207
|
+
function loadPagedRows() {
|
|
208
|
+
if (pagedState.value !== "idle") return;
|
|
209
|
+
pagedState.value = "loading";
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
pagedRows.value = [
|
|
212
|
+
...pagedRows.value,
|
|
213
|
+
...Array.from({ length: 10 }, (_, i) => `Row ${pagedRows.value.length + i + 1}`),
|
|
214
|
+
];
|
|
215
|
+
pagedState.value = pagedRows.value.length >= 60 ? "end" : "idle";
|
|
216
|
+
}, 500);
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
return () => (
|
|
201
220
|
<div class="demo">
|
|
202
221
|
<header class="demo-header">
|
|
@@ -225,6 +244,22 @@ export default defineComponent({
|
|
|
225
244
|
</div>
|
|
226
245
|
</section>
|
|
227
246
|
|
|
247
|
+
<section>
|
|
248
|
+
<h2>HScrollContainer + HLoadMore</h2>
|
|
249
|
+
<HScrollContainer
|
|
250
|
+
style={{ height: "320px" }}
|
|
251
|
+
approachEnd
|
|
252
|
+
onApproachEnd={loadPagedRows}
|
|
253
|
+
>
|
|
254
|
+
<div style={{ display: "flex", flexDirection: "column" }}>
|
|
255
|
+
{pagedRows.value.map((r) => (
|
|
256
|
+
<div key={r} style={{ padding: "6px 12px", borderBottom: "1px solid rgb(var(--color-muted, 108 108 108) / 18%)" }}>{r}</div>
|
|
257
|
+
))}
|
|
258
|
+
<HLoadMore state={pagedState.value} shown={pagedRows.value.length} total={60} onLoadMore={loadPagedRows} />
|
|
259
|
+
</div>
|
|
260
|
+
</HScrollContainer>
|
|
261
|
+
</section>
|
|
262
|
+
|
|
228
263
|
<section>
|
|
229
264
|
<h2>HIconButton</h2>
|
|
230
265
|
<div class="row">
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* HkLoadMore — the unified bottom indicator for dynamically loaded
|
|
3
|
+
* lists. Hairline rules flank the control so the indicator reads as
|
|
4
|
+
* "the list pauses here" rather than a stray button; everything is
|
|
5
|
+
* token-driven (no hardcoded colors) so themes restyle it for free.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
.hk-load-more {
|
|
9
|
+
display: flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: var(--space-12);
|
|
12
|
+
padding: var(--space-12) 0;
|
|
13
|
+
color: rgb(var(--color-text-tertiary));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* Hairline rules: transparent at the outer edge, muted towards the
|
|
17
|
+
* control; the end rule mirrors the gradient. */
|
|
18
|
+
.hk-load-more-rule {
|
|
19
|
+
flex: 1;
|
|
20
|
+
height: 1px;
|
|
21
|
+
background: linear-gradient(to right, transparent, rgb(var(--color-muted) / 35%));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.hk-load-more-rule-end {
|
|
25
|
+
background: linear-gradient(to left, transparent, rgb(var(--color-muted) / 35%));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.hk-load-more-core {
|
|
29
|
+
flex: none;
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: var(--space-10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Delivery progress ("3 / 19"): mono, tabular so the digits don't
|
|
36
|
+
* shimmer while counting up. */
|
|
37
|
+
.hk-load-more-count {
|
|
38
|
+
font-family: var(--font-mono);
|
|
39
|
+
font-size: var(--text-2xs);
|
|
40
|
+
font-variant-numeric: tabular-nums;
|
|
41
|
+
letter-spacing: 0.04em;
|
|
42
|
+
color: rgb(var(--color-text-tertiary));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-load-more[data-state="loading"] .hk-load-more-count {
|
|
46
|
+
opacity: 0.6;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.hk-load-more-end {
|
|
50
|
+
font-size: var(--text-2xs);
|
|
51
|
+
letter-spacing: 0.08em;
|
|
52
|
+
color: rgb(var(--color-text-tertiary));
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkLoadMore from "./HkLoadMore";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mount(node: ReturnType<typeof h>) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({ render: () => node });
|
|
12
|
+
app.mount(container);
|
|
13
|
+
mounts.push({ app, container });
|
|
14
|
+
return container;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
19
|
+
app.unmount();
|
|
20
|
+
container.remove();
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The en bundle loads eagerly, so the two-arg t() fallbacks double as
|
|
25
|
+
* the assertions' expected strings — no i18n test setup needed. */
|
|
26
|
+
describe("HkLoadMore", () => {
|
|
27
|
+
it("idle renders the load-more button and the shown/total counter", () => {
|
|
28
|
+
const c = mount(h(HkLoadMore, { shown: 3, total: 19 }));
|
|
29
|
+
const button = c.querySelector("button.hk-btn");
|
|
30
|
+
expect(button).not.toBeNull();
|
|
31
|
+
expect(button!.textContent).toContain("Load more");
|
|
32
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("3 / 19");
|
|
33
|
+
expect(c.querySelector(".hk-load-more")!.getAttribute("data-state")).toBe("idle");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clicking the button emits load-more", () => {
|
|
37
|
+
const fired: string[] = [];
|
|
38
|
+
const c = mount(h(HkLoadMore, { onLoadMore: () => fired.push("more") }));
|
|
39
|
+
(c.querySelector("button.hk-btn") as HTMLButtonElement).click();
|
|
40
|
+
expect(fired).toEqual(["more"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("loading state disables the button with aria-busy and the loading label", () => {
|
|
44
|
+
const c = mount(h(HkLoadMore, { state: "loading", shown: 3, total: 19 }));
|
|
45
|
+
const button = c.querySelector("button.hk-btn") as HTMLButtonElement;
|
|
46
|
+
expect(button.disabled).toBe(true);
|
|
47
|
+
expect(button.getAttribute("aria-busy")).toBe("true");
|
|
48
|
+
expect(button.textContent).toContain("Loading…");
|
|
49
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("end state swaps the button for the reached-the-end status note", () => {
|
|
53
|
+
const c = mount(h(HkLoadMore, { state: "end", shown: 19, total: 19 }));
|
|
54
|
+
expect(c.querySelector("button.hk-btn")).toBeNull();
|
|
55
|
+
const note = c.querySelector('[role="status"]');
|
|
56
|
+
expect(note).not.toBeNull();
|
|
57
|
+
expect(note!.textContent).toContain("You've reached the end");
|
|
58
|
+
expect(c.querySelector(".hk-load-more-count")!.textContent).toBe("19 / 19");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("omits the counter entirely when shown/total are absent", () => {
|
|
62
|
+
const c = mount(h(HkLoadMore));
|
|
63
|
+
expect(c.querySelector(".hk-load-more-count")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineComponent, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkLoadMore.scss";
|
|
4
|
+
import { useI18n } from "../i18n/context";
|
|
5
|
+
import HButton from "./HkButton";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The unified bottom indicator for dynamically loaded lists — one look
|
|
9
|
+
* for every consumer (cruise panels, tables, feeds), three states:
|
|
10
|
+
*
|
|
11
|
+
* - `idle`: a ghost load-more button; clicking emits `loadMore`.
|
|
12
|
+
* - `loading`: the SAME button with its built-in spinner (HkButton
|
|
13
|
+
* swaps in an HSpinner while loading) — no second spinner here, the
|
|
14
|
+
* affordance morphs in place at a stable height.
|
|
15
|
+
* - `end`: a muted "reached the end" note replacing the button.
|
|
16
|
+
*
|
|
17
|
+
* Optional `shown`/`total` render a tabular mono counter next to the
|
|
18
|
+
* control in every state ("3 / 19"), giving users the delivery
|
|
19
|
+
* progress without each app inventing its own wording.
|
|
20
|
+
*/
|
|
21
|
+
export default defineComponent({
|
|
22
|
+
name: "HkLoadMore",
|
|
23
|
+
props: {
|
|
24
|
+
state: { type: String as PropType<"idle" | "loading" | "end">, default: "idle" },
|
|
25
|
+
/** Delivered item count (with `total`, renders the progress counter). */
|
|
26
|
+
shown: { type: Number, default: undefined },
|
|
27
|
+
/** Full item count behind the windowed list. */
|
|
28
|
+
total: { type: Number, default: undefined },
|
|
29
|
+
},
|
|
30
|
+
emits: ["loadMore"],
|
|
31
|
+
setup(props, { emit }) {
|
|
32
|
+
const { t } = useI18n();
|
|
33
|
+
return () => {
|
|
34
|
+
const hasCount =
|
|
35
|
+
typeof props.shown === "number" && typeof props.total === "number";
|
|
36
|
+
const loading = props.state === "loading";
|
|
37
|
+
return (
|
|
38
|
+
<div class="hk-load-more" data-state={props.state}>
|
|
39
|
+
<span class="hk-load-more-rule" aria-hidden="true" />
|
|
40
|
+
<span class="hk-load-more-core">
|
|
41
|
+
{props.state === "end" ? (
|
|
42
|
+
<span class="hk-load-more-end" role="status">
|
|
43
|
+
{t("hikari::loadMore.end", "You've reached the end")}
|
|
44
|
+
</span>
|
|
45
|
+
) : (
|
|
46
|
+
<HButton
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="sm"
|
|
49
|
+
icon="ChevronDown"
|
|
50
|
+
loading={loading}
|
|
51
|
+
aria-label={
|
|
52
|
+
loading
|
|
53
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
54
|
+
: t("hikari::loadMore.more", "Load more")
|
|
55
|
+
}
|
|
56
|
+
onClick={() => emit("loadMore")}
|
|
57
|
+
>
|
|
58
|
+
{loading
|
|
59
|
+
? t("hikari::loadMore.loading", "Loading…")
|
|
60
|
+
: t("hikari::loadMore.more", "Load more")}
|
|
61
|
+
</HButton>
|
|
62
|
+
)}
|
|
63
|
+
{hasCount ? (
|
|
64
|
+
<span class="hk-load-more-count">{props.shown} / {props.total}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</span>
|
|
67
|
+
<span class="hk-load-more-rule hk-load-more-rule-end" aria-hidden="true" />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -11,6 +11,8 @@ interface Mounted {
|
|
|
11
11
|
instance: {
|
|
12
12
|
refresh: () => void;
|
|
13
13
|
getOverflow: () => { horizontal: string; vertical: string };
|
|
14
|
+
isNearEnd?: () => boolean;
|
|
15
|
+
recheck?: () => void;
|
|
14
16
|
} | null;
|
|
15
17
|
}
|
|
16
18
|
|
|
@@ -47,7 +49,7 @@ function mountScroller(props: Record<string, unknown> = {}, slotText = "content"
|
|
|
47
49
|
/** happy-dom performs no layout, so scroll geometry is stubbed on the
|
|
48
50
|
* viewport instance (shadowing the prototype getters) before a
|
|
49
51
|
* refresh() pass re-senses the overflow. */
|
|
50
|
-
function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientWidth?: number; scrollLeft?: number }) {
|
|
52
|
+
function stubGeometry(el: HTMLElement, geom: { scrollWidth?: number; clientHeight?: number; clientWidth?: number; scrollLeft?: number; scrollHeight?: number; scrollTop?: number }) {
|
|
51
53
|
const desc: PropertyDescriptorMap = {};
|
|
52
54
|
for (const [key, value] of Object.entries(geom)) {
|
|
53
55
|
desc[key] = { configurable: true, get: () => value };
|
|
@@ -158,3 +160,44 @@ describe("HkScrollContainer scrollbar reactivity", () => {
|
|
|
158
160
|
expect(root.querySelectorAll(".hk-scrollbar-track").length).toBe(0);
|
|
159
161
|
});
|
|
160
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("HkScrollContainer approachEnd", () => {
|
|
165
|
+
/** Two awaited frames: scheduleFrame's scheduling rAF + the composable's
|
|
166
|
+
* onceFrame initial sensing pass. */
|
|
167
|
+
async function flushFrames(): Promise<void> {
|
|
168
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
169
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it("emits approachEnd for under-filled content and exposes isNearEnd", async () => {
|
|
173
|
+
const emissions: number[] = [];
|
|
174
|
+
const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
|
|
175
|
+
// happy-dom has no layout; a real under-filled DOM reports the
|
|
176
|
+
// CSSOM clamp (scrollHeight == clientHeight), so stub THAT.
|
|
177
|
+
stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
|
|
178
|
+
await flushFrames();
|
|
179
|
+
expect(emissions.length).toBe(1);
|
|
180
|
+
expect(m.instance?.isNearEnd?.()).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("does not emit when the prop is off", async () => {
|
|
184
|
+
const emissions: number[] = [];
|
|
185
|
+
mountScroller({ onApproachEnd: () => emissions.push(1) });
|
|
186
|
+
await flushFrames();
|
|
187
|
+
expect(emissions.length).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("exposes recheck and honours it at the container level", async () => {
|
|
191
|
+
const emissions: number[] = [];
|
|
192
|
+
const m = mountScroller({ approachEnd: true, onApproachEnd: () => emissions.push(1) });
|
|
193
|
+
stubGeometry(m.viewport, { scrollHeight: 300, clientHeight: 300, scrollTop: 0 });
|
|
194
|
+
await flushFrames();
|
|
195
|
+
expect(emissions.length).toBe(1);
|
|
196
|
+
// Pin recheck on the exposed surface: dropping it from expose() must
|
|
197
|
+
// fail here, not just at the composable level.
|
|
198
|
+
expect(typeof m.instance?.recheck).toBe("function");
|
|
199
|
+
m.instance?.recheck?.();
|
|
200
|
+
await flushFrames();
|
|
201
|
+
expect(emissions.length).toBe(2);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import { useI18n } from "../i18n/context";
|
|
13
13
|
import "./HkScrollContainer.scss";
|
|
14
14
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
|
|
15
|
+
import { useApproachEnd, type ApproachEndHandle } from "../composables/useApproachEnd";
|
|
15
16
|
import { provideScrollWindow } from "../composables/useScrollWindow";
|
|
16
17
|
import { scheduleFrame, notifyScrollStart, onceFrame, type AnimationHandle } from "../runtime/animationBus";
|
|
17
18
|
import HFab from "./HkFab";
|
|
@@ -61,8 +62,25 @@ export default defineComponent({
|
|
|
61
62
|
* scrolled away from the bottom, float a small jump-back FAB that
|
|
62
63
|
* snaps to the latest content and re-arms follow. */
|
|
63
64
|
followAffordance: { type: Boolean, default: false },
|
|
65
|
+
/** Additive opt-in: sense "the viewport is at/near the end of its
|
|
66
|
+
* content" and emit `approachEnd` so the consumer can load the
|
|
67
|
+
* next page of data (infinite/dynamic loading). The event fires on
|
|
68
|
+
* zone entry and whenever the content geometry grows while still
|
|
69
|
+
* in the zone — including every pass while the content does not
|
|
70
|
+
* fill the viewport (CSSOM clamps scrollHeight there, so the
|
|
71
|
+
* under-filled state always reports; that is what lets a consumer
|
|
72
|
+
* auto-fill a first screen). Fire-and-dedup contract: guard the
|
|
73
|
+
* handler with your own loading flag; when a load renders nothing
|
|
74
|
+
* new (all filtered out downstream), call the exposed `recheck()`
|
|
75
|
+
* to force the next emission. */
|
|
76
|
+
approachEnd: { type: Boolean, default: false },
|
|
77
|
+
/** Distance in px from the vertical end that still counts as
|
|
78
|
+
* approaching (`approachEnd`). Read live, so runtime changes need
|
|
79
|
+
* no remount. */
|
|
80
|
+
approachDistance: { type: Number, default: 160 },
|
|
64
81
|
},
|
|
65
|
-
|
|
82
|
+
emits: { approachEnd: () => true },
|
|
83
|
+
setup(props, { slots, expose, emit }) {
|
|
66
84
|
const { t } = useI18n();
|
|
67
85
|
const viewportRef = ref<HTMLElement>();
|
|
68
86
|
let ro: ResizeObserver | null = null;
|
|
@@ -87,6 +105,31 @@ export default defineComponent({
|
|
|
87
105
|
let followRO: ResizeObserver | null = null;
|
|
88
106
|
const showAutoTag = computed(() => props.autoFollow && props.scrollbar && pinned.value);
|
|
89
107
|
|
|
108
|
+
// End-approach sensing (infinite loading): the composable owns the
|
|
109
|
+
// scroll/resize/mutation sensors; this component only owns the
|
|
110
|
+
// lifecycle and forwards the emission. `distance` is passed as a
|
|
111
|
+
// getter so runtime approachDistance changes need no restart.
|
|
112
|
+
const approachHandle = shallowRef<ApproachEndHandle | null>(null);
|
|
113
|
+
|
|
114
|
+
function startApproach() {
|
|
115
|
+
if (approachHandle.value) return;
|
|
116
|
+
approachHandle.value = useApproachEnd(
|
|
117
|
+
viewportRef as unknown as import("vue").Ref<HTMLElement | null>,
|
|
118
|
+
() => emit("approachEnd"),
|
|
119
|
+
{ distance: () => props.approachDistance },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stopApproach() {
|
|
124
|
+
approachHandle.value?.stop();
|
|
125
|
+
approachHandle.value = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
watch(() => props.approachEnd, (on) => {
|
|
129
|
+
if (on) startApproach();
|
|
130
|
+
else stopApproach();
|
|
131
|
+
}, { flush: "post" });
|
|
132
|
+
|
|
90
133
|
if (props.mode === "windowed") {
|
|
91
134
|
provideScrollWindow(viewportRef as unknown as import("vue").Ref<HTMLElement | null>, props.overscanScreens);
|
|
92
135
|
}
|
|
@@ -255,6 +298,8 @@ export default defineComponent({
|
|
|
255
298
|
mountScrollbars();
|
|
256
299
|
}
|
|
257
300
|
|
|
301
|
+
if (props.approachEnd) startApproach();
|
|
302
|
+
|
|
258
303
|
vp.addEventListener("scroll", onScroll, { passive: true });
|
|
259
304
|
vp.addEventListener("wheel", onWheel, { passive: false });
|
|
260
305
|
|
|
@@ -287,6 +332,7 @@ export default defineComponent({
|
|
|
287
332
|
}
|
|
288
333
|
overlay?.detach();
|
|
289
334
|
overlay = null;
|
|
335
|
+
stopApproach();
|
|
290
336
|
scheduled?.disconnect();
|
|
291
337
|
settleHandle?.disconnect();
|
|
292
338
|
settleHandle = null;
|
|
@@ -341,7 +387,22 @@ export default defineComponent({
|
|
|
341
387
|
};
|
|
342
388
|
}
|
|
343
389
|
|
|
344
|
-
|
|
390
|
+
/** True while the viewport sits within `approachDistance` of its
|
|
391
|
+
* vertical end (only meaningful with `approachEnd`). */
|
|
392
|
+
function isNearEnd(): boolean {
|
|
393
|
+
return approachHandle.value?.isNearEnd() ?? false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Force one end-approach sensing pass that ignores the
|
|
397
|
+
* same-geometry dedup. Call after a load that may still leave the
|
|
398
|
+
* content under-filled — while under-filled the sensor fires on
|
|
399
|
+
* its own, but a consumer whose load rendered nothing new (all
|
|
400
|
+
* filtered out downstream) needs this to keep the walk going. */
|
|
401
|
+
function recheck(): void {
|
|
402
|
+
approachHandle.value?.recheck();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
expose({ scrollTo, scrollToElement, getScrollElement, getScrollTop, refresh, getOverflow, isNearEnd, recheck });
|
|
345
406
|
|
|
346
407
|
return () => {
|
|
347
408
|
const Tag = props.as as "div" | "section" | "nav" | "main" | "aside";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, type VNode } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkTag from "./HkTag";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mountTag(props: Record<string, unknown> = {}, children?: () => VNode) {
|
|
9
|
+
const events = { close: 0 };
|
|
10
|
+
const container = document.createElement("div");
|
|
11
|
+
document.body.appendChild(container);
|
|
12
|
+
const app = createApp({
|
|
13
|
+
render: () =>
|
|
14
|
+
h(
|
|
15
|
+
HkTag,
|
|
16
|
+
{
|
|
17
|
+
...props,
|
|
18
|
+
onClose: () => {
|
|
19
|
+
events.close++;
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
children ? { default: children } : undefined,
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
app.mount(container);
|
|
26
|
+
mounts.push({ app, container });
|
|
27
|
+
return { events, container };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
32
|
+
app.unmount();
|
|
33
|
+
container.remove();
|
|
34
|
+
}
|
|
35
|
+
document.body.innerHTML = "";
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("HkTag", () => {
|
|
39
|
+
it("renders slot content without a close button by default", () => {
|
|
40
|
+
const { container } = mountTag({}, () => h("span", "Default"));
|
|
41
|
+
const tag = container.querySelector(".hk-tag")!;
|
|
42
|
+
expect(tag.textContent).toBe("Default");
|
|
43
|
+
expect(tag.querySelector(".hk-tag-close")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("closable renders the × which emits close", () => {
|
|
47
|
+
const { events, container } = mountTag({ closable: true }, () => h("span", "Closable"));
|
|
48
|
+
const close = container.querySelector<HTMLButtonElement>(".hk-tag-close")!;
|
|
49
|
+
expect(close).toBeTruthy();
|
|
50
|
+
// The pre-prop markup: an unlabeled button, exactly as before.
|
|
51
|
+
expect(close.hasAttribute("aria-label")).toBe(false);
|
|
52
|
+
close.click();
|
|
53
|
+
expect(events.close).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("closeLabel names the close button for screen readers", () => {
|
|
57
|
+
const { events, container } = mountTag(
|
|
58
|
+
{ closable: true, closeLabel: "Remove — News" },
|
|
59
|
+
() => h("span", "News"),
|
|
60
|
+
);
|
|
61
|
+
const close = container.querySelector<HTMLButtonElement>(".hk-tag-close")!;
|
|
62
|
+
expect(close.getAttribute("aria-label")).toBe("Remove — News");
|
|
63
|
+
close.click();
|
|
64
|
+
expect(events.close).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps the variant and size classes untouched", () => {
|
|
68
|
+
const { container } = mountTag(
|
|
69
|
+
{ variant: "success", size: "sm", closable: true, closeLabel: "Remove" },
|
|
70
|
+
() => h("span", "Done"),
|
|
71
|
+
);
|
|
72
|
+
const tag = container.querySelector(".hk-tag")!;
|
|
73
|
+
expect(tag.classList.contains("hk-tag-success")).toBe(true);
|
|
74
|
+
expect(tag.classList.contains("hk-tag-sm")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/components/HkTag.tsx
CHANGED
|
@@ -11,6 +11,13 @@ export default defineComponent({
|
|
|
11
11
|
variant: { type: String as PropType<TagVariant>, default: "default" },
|
|
12
12
|
size: { type: String as PropType<"sm" | "md">, default: "md" },
|
|
13
13
|
closable: { type: Boolean, default: false },
|
|
14
|
+
/**
|
|
15
|
+
* Accessible name of the close button, rendered as its `aria-label`.
|
|
16
|
+
* Undefined (the default) renders exactly the pre-prop markup, so
|
|
17
|
+
* existing consumers are untouched; tag fields that can carry several
|
|
18
|
+
* chips side by side set it to name the entry each × removes.
|
|
19
|
+
*/
|
|
20
|
+
closeLabel: { type: String, default: undefined },
|
|
14
21
|
},
|
|
15
22
|
emits: {
|
|
16
23
|
close: () => true,
|
|
@@ -34,6 +41,7 @@ export default defineComponent({
|
|
|
34
41
|
<button
|
|
35
42
|
type="button"
|
|
36
43
|
class="hk-tag-close"
|
|
44
|
+
aria-label={props.closeLabel}
|
|
37
45
|
onClick={() => emit("close")}
|
|
38
46
|
>
|
|
39
47
|
<X size={12} />
|