@celestia-island/hikari 0.41.9 → 0.42.2
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/HkColorSchemeDialog.test.ts +14 -8
- package/src/components/HkColorSchemeEditor.test.ts +35 -11
- package/src/components/HkColorSchemeEditor.tsx +54 -31
- package/src/components/HkImageLightbox.contract.test.ts +44 -0
- package/src/components/HkImageLightbox.scss +34 -1
- package/src/components/HkImageViewer.tsx +8 -1
- 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/HkMinimap.scss +10 -0
- package/src/components/HkMinimap.toolbarOnly.test.tsx +54 -0
- package/src/components/HkMinimap.tsx +44 -35
- 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/components/HkThemeToggle.contract.test.ts +28 -0
- package/src/components/HkThemeToggle.scss +11 -1
- 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/ar/platform.json +1 -0
- package/src/i18n/locales/de/components.json +9 -1
- package/src/i18n/locales/de/platform.json +1 -0
- package/src/i18n/locales/en/components.json +9 -1
- package/src/i18n/locales/en/platform.json +1 -0
- package/src/i18n/locales/es/components.json +9 -1
- package/src/i18n/locales/es/platform.json +1 -0
- package/src/i18n/locales/fr/components.json +9 -1
- package/src/i18n/locales/fr/platform.json +1 -0
- package/src/i18n/locales/ja/components.json +9 -1
- package/src/i18n/locales/ja/platform.json +1 -0
- package/src/i18n/locales/ko/components.json +9 -1
- package/src/i18n/locales/ko/platform.json +1 -0
- package/src/i18n/locales/pt/components.json +9 -1
- package/src/i18n/locales/pt/platform.json +1 -0
- package/src/i18n/locales/ru/components.json +9 -1
- package/src/i18n/locales/ru/platform.json +1 -0
- package/src/i18n/locales/zh-Hans/components.json +9 -1
- package/src/i18n/locales/zh-Hans/platform.json +1 -0
- package/src/i18n/locales/zh-Hant/components.json +9 -1
- package/src/i18n/locales/zh-Hant/platform.json +1 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkMinimap from "./HkMinimap";
|
|
5
|
+
|
|
6
|
+
const mounts: ReturnType<typeof createApp>[] = [];
|
|
7
|
+
const containers: HTMLElement[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
for (const app of mounts.splice(0)) app.unmount();
|
|
11
|
+
for (const el of containers.splice(0)) el.remove();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function mountMinimap(props: Record<string, unknown>): HTMLElement {
|
|
15
|
+
const container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
containers.push(container);
|
|
18
|
+
|
|
19
|
+
const Host = defineComponent({
|
|
20
|
+
setup() {
|
|
21
|
+
return () => h("div", [h(HkMinimap, props)]);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const app = createApp(Host);
|
|
25
|
+
mounts.push(app);
|
|
26
|
+
app.mount(container);
|
|
27
|
+
return container;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("HkMinimap toolbar-only form", () => {
|
|
31
|
+
it("renders nothing without boxes, an image, or the toolbar-only flag", () => {
|
|
32
|
+
const c = mountMinimap({ zoomPercent: 100 });
|
|
33
|
+
expect(c.querySelector(".hk-minimap")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders only the zoom bar under toolbarOnly (no overview map)", () => {
|
|
37
|
+
const c = mountMinimap({ zoomPercent: 100, toolbarOnly: true });
|
|
38
|
+
const root = c.querySelector<HTMLElement>(".hk-minimap");
|
|
39
|
+
expect(root).not.toBeNull();
|
|
40
|
+
expect(root!.hasAttribute("data-toolbar-only")).toBe(true);
|
|
41
|
+
expect(root!.querySelector(".hk-minimap-svg")).toBeNull();
|
|
42
|
+
expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
|
|
43
|
+
expect(root!.querySelectorAll(".hk-mm-zoom-btn").length).toBe(2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps the overview map when toolbarOnly is not set and an image is given", () => {
|
|
47
|
+
const c = mountMinimap({ imageSrc: "data:image/gif;base64,", toolbarOnly: false });
|
|
48
|
+
const root = c.querySelector<HTMLElement>(".hk-minimap");
|
|
49
|
+
expect(root).not.toBeNull();
|
|
50
|
+
expect(root!.hasAttribute("data-toolbar-only")).toBe(false);
|
|
51
|
+
expect(root!.querySelector(".hk-minimap-svg")).not.toBeNull();
|
|
52
|
+
expect(root!.querySelector(".hk-mm-zoom-bar")).not.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -65,6 +65,12 @@ export default defineComponent({
|
|
|
65
65
|
/** Show the reset/fit button in the zoom bar (chest only rendered it
|
|
66
66
|
* when a reset handler was wired up). */
|
|
67
67
|
showReset: { type: Boolean, default: false },
|
|
68
|
+
/** Toolbar-only form: render just the zoom bar, no overview map. For
|
|
69
|
+
* hosts whose viewport rect is uninformative at rest (e.g. the image
|
|
70
|
+
* lightbox at fit zoom) but that still want the zoom controls
|
|
71
|
+
* visible; the map is expected to reappear once it has something to
|
|
72
|
+
* say (the viewer switches this off when zoomed past fit). */
|
|
73
|
+
toolbarOnly: { type: Boolean, default: false },
|
|
68
74
|
/** Optional prop-callback surface (alternative to the emits). */
|
|
69
75
|
onZoomTo: { type: Function as PropType<(percent: number) => void>, default: undefined },
|
|
70
76
|
onReset: { type: Function as PropType<() => void>, default: undefined },
|
|
@@ -180,7 +186,7 @@ export default defineComponent({
|
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
return () => {
|
|
183
|
-
if (props.boxes.length === 0 && !props.imageSrc) return null;
|
|
189
|
+
if (props.boxes.length === 0 && !props.imageSrc && !props.toolbarOnly) return null;
|
|
184
190
|
|
|
185
191
|
const s = scale.value;
|
|
186
192
|
const vr = viewportRect.value;
|
|
@@ -213,42 +219,45 @@ export default defineComponent({
|
|
|
213
219
|
ref={rootRef}
|
|
214
220
|
class="hk-minimap"
|
|
215
221
|
data-dragging={dragging.value ? "" : undefined}
|
|
222
|
+
data-toolbar-only={props.toolbarOnly ? "" : undefined}
|
|
216
223
|
>
|
|
217
|
-
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
{!props.toolbarOnly && (
|
|
225
|
+
<svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
|
|
226
|
+
{imgPos && (
|
|
227
|
+
<image
|
|
228
|
+
href={props.imageSrc}
|
|
229
|
+
x={imgPos[0]}
|
|
230
|
+
y={imgPos[1]}
|
|
231
|
+
width={ib.w * s}
|
|
232
|
+
height={ib.h * s}
|
|
233
|
+
preserveAspectRatio="none"
|
|
234
|
+
class="hk-mm-image"
|
|
235
|
+
/>
|
|
236
|
+
)}
|
|
237
|
+
{boxRects}
|
|
238
|
+
{hubP && (
|
|
239
|
+
<circle
|
|
240
|
+
cx={hubP[0]}
|
|
241
|
+
cy={hubP[1]}
|
|
242
|
+
r={3}
|
|
243
|
+
fill="rgb(var(--color-primary))"
|
|
244
|
+
filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
|
|
245
|
+
/>
|
|
246
|
+
)}
|
|
247
|
+
<rect
|
|
248
|
+
x={vr.x}
|
|
249
|
+
y={vr.y}
|
|
250
|
+
width={Math.max(1, vr.w)}
|
|
251
|
+
height={Math.max(1, vr.h)}
|
|
252
|
+
fill="none"
|
|
253
|
+
stroke="rgb(var(--color-primary))"
|
|
254
|
+
stroke-width="1"
|
|
255
|
+
stroke-dasharray="3 2"
|
|
256
|
+
rx="2"
|
|
257
|
+
opacity="0.85"
|
|
227
258
|
/>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
{hubP && (
|
|
231
|
-
<circle
|
|
232
|
-
cx={hubP[0]}
|
|
233
|
-
cy={hubP[1]}
|
|
234
|
-
r={3}
|
|
235
|
-
fill="rgb(var(--color-primary))"
|
|
236
|
-
filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
|
|
237
|
-
/>
|
|
238
|
-
)}
|
|
239
|
-
<rect
|
|
240
|
-
x={vr.x}
|
|
241
|
-
y={vr.y}
|
|
242
|
-
width={Math.max(1, vr.w)}
|
|
243
|
-
height={Math.max(1, vr.h)}
|
|
244
|
-
fill="none"
|
|
245
|
-
stroke="rgb(var(--color-primary))"
|
|
246
|
-
stroke-width="1"
|
|
247
|
-
stroke-dasharray="3 2"
|
|
248
|
-
rx="2"
|
|
249
|
-
opacity="0.85"
|
|
250
|
-
/>
|
|
251
|
-
</svg>
|
|
259
|
+
</svg>
|
|
260
|
+
)}
|
|
252
261
|
<div class="hk-mm-zoom-bar">
|
|
253
262
|
<button
|
|
254
263
|
class="hk-mm-zoom-btn"
|
|
@@ -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} />
|