@celestia-island/hikari 0.38.2 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/DemoApp.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// house rule: no SFCs in the tree). Render-only showcase of every exported
|
|
3
3
|
// component; state is local and throwaway.
|
|
4
4
|
import { computed, defineComponent, ref } from "vue";
|
|
5
|
+
import { PackageOpen } from "lucide-vue-next";
|
|
5
6
|
import { HIKARI_FONT_MONO } from "./theme/fontContext";
|
|
6
7
|
import {
|
|
7
8
|
HButton, HIconButton, HTooltip, HBadge, HTag, HIcon, HSpinner,
|
|
@@ -360,11 +361,30 @@ export default defineComponent({
|
|
|
360
361
|
|
|
361
362
|
<section>
|
|
362
363
|
<h2>HEmptyState</h2>
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
<div class="col">
|
|
365
|
+
<HEmptyState
|
|
366
|
+
title="No items"
|
|
367
|
+
description="Nothing to show here yet."
|
|
368
|
+
v-slots={{ action: () => <HButton size="sm">Create item</HButton> }}
|
|
369
|
+
/>
|
|
370
|
+
<HEmptyState
|
|
371
|
+
title="Nothing found"
|
|
372
|
+
description="Search returned no matches — try a different query."
|
|
373
|
+
icon={PackageOpen}
|
|
374
|
+
/>
|
|
375
|
+
<HEmptyState
|
|
376
|
+
title="Custom icon slot"
|
|
377
|
+
v-slots={{ icon: () => <HIcon name="inbox" size={48} /> }}
|
|
378
|
+
/>
|
|
379
|
+
<HEmptyState loading />
|
|
380
|
+
<div style="height:220px">
|
|
381
|
+
<HEmptyState
|
|
382
|
+
title="Panel empty"
|
|
383
|
+
description={'fit="fill" stretches the card to its host container.'}
|
|
384
|
+
fit="fill"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
368
388
|
</section>
|
|
369
389
|
|
|
370
390
|
<section>
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
backdrop-filter: blur(8px);
|
|
10
10
|
border: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.06));
|
|
11
11
|
border-radius: var(--hi-radius-md, 0.5rem);
|
|
12
|
+
box-sizing: border-box;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
.hk-empty-icon {
|
|
@@ -40,3 +41,44 @@
|
|
|
40
41
|
.hk-empty-action {
|
|
41
42
|
margin-top: var(--hi-space-4, 0.25rem);
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
/* fit="page" (default): full width on mobile — the page supplies the outer
|
|
46
|
+
padding — and centered. */
|
|
47
|
+
.hk-empty-state--page {
|
|
48
|
+
width: 100%;
|
|
49
|
+
margin-inline: auto;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Desktop: the card must not span the page — cap around 30vw with a sane
|
|
53
|
+
floor/cap (320px … 560px) so tiny and 4K windows stay usable. */
|
|
54
|
+
@media (min-width: 48rem) {
|
|
55
|
+
.hk-empty-state--page {
|
|
56
|
+
max-width: clamp(20rem, 30vw, 35rem);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* fit="fill": stretch to the host container (panels, split views). */
|
|
61
|
+
.hk-empty-state--fill {
|
|
62
|
+
width: 100%;
|
|
63
|
+
height: 100%;
|
|
64
|
+
min-height: 100%;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* Loading variant keeps a comfortable spinner area in short hosts. */
|
|
68
|
+
.hk-empty-state--loading {
|
|
69
|
+
min-height: 10rem;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Visually hidden but announced: gives the loading status region text
|
|
73
|
+
content so screen readers have something to say when it appears. */
|
|
74
|
+
.hk-empty-sr-only {
|
|
75
|
+
position: absolute;
|
|
76
|
+
width: 1px;
|
|
77
|
+
height: 1px;
|
|
78
|
+
padding: 0;
|
|
79
|
+
margin: -1px;
|
|
80
|
+
overflow: hidden;
|
|
81
|
+
clip: rect(0, 0, 0, 0);
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
border: 0;
|
|
84
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkEmptyState from "./HkEmptyState";
|
|
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
|
+
/* Functional icon stub rendering a node with a distinguishing class so the
|
|
25
|
+
tests can assert the icon prop component actually mounted. */
|
|
26
|
+
const StubIcon = defineComponent({
|
|
27
|
+
name: "StubIcon",
|
|
28
|
+
setup() {
|
|
29
|
+
return () => h("i", { class: "stub-empty-icon", "data-stub-icon": "true" });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("HkEmptyState", () => {
|
|
34
|
+
it("renders title and description", () => {
|
|
35
|
+
const c = mount(
|
|
36
|
+
h(HkEmptyState, { title: "No items", description: "Nothing here yet." }),
|
|
37
|
+
);
|
|
38
|
+
expect((c.querySelector(".hk-empty-title") as HTMLElement).textContent).toBe(
|
|
39
|
+
"No items",
|
|
40
|
+
);
|
|
41
|
+
expect((c.querySelector(".hk-empty-desc") as HTMLElement).textContent).toBe(
|
|
42
|
+
"Nothing here yet.",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders action slot content inside .hk-empty-action", () => {
|
|
47
|
+
const c = mount(
|
|
48
|
+
h(
|
|
49
|
+
HkEmptyState,
|
|
50
|
+
{ title: "No items" },
|
|
51
|
+
{ action: () => h("button", { class: "stub-action" }, "Retry") },
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
const btn = c.querySelector(".hk-empty-action .stub-action") as HTMLElement;
|
|
55
|
+
expect(btn).not.toBeNull();
|
|
56
|
+
expect(btn.textContent).toBe("Retry");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders the icon prop component in the icon well", () => {
|
|
60
|
+
const c = mount(h(HkEmptyState, { title: "No items", icon: StubIcon }));
|
|
61
|
+
expect(c.querySelector(".hk-empty-icon .stub-empty-icon")).not.toBeNull();
|
|
62
|
+
// The prop icon replaces the default inline inbox svg.
|
|
63
|
+
expect(c.querySelector(".hk-empty-icon svg polyline")).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("prefers the icon slot over the icon prop", () => {
|
|
67
|
+
const c = mount(
|
|
68
|
+
h(
|
|
69
|
+
HkEmptyState,
|
|
70
|
+
{ title: "No items", icon: StubIcon },
|
|
71
|
+
{ icon: () => h("b", { class: "slot-icon" }) },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
expect(c.querySelector(".hk-empty-icon .slot-icon")).not.toBeNull();
|
|
75
|
+
expect(c.querySelector(".stub-empty-icon")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders the default inline svg when no icon prop and no slot", () => {
|
|
79
|
+
const c = mount(h(HkEmptyState, { title: "No items" }));
|
|
80
|
+
expect(c.querySelector(".hk-empty-icon svg polyline")).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("loading variant renders only a spinner with status semantics", () => {
|
|
84
|
+
const c = mount(
|
|
85
|
+
h(
|
|
86
|
+
HkEmptyState,
|
|
87
|
+
{ title: "No items", description: "desc", loading: true },
|
|
88
|
+
{ action: () => h("button", "x") },
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
const root = c.querySelector(".hk-empty-state") as HTMLElement;
|
|
92
|
+
expect(root.className).toContain("hk-empty-state--loading");
|
|
93
|
+
expect(root.getAttribute("role")).toBe("status");
|
|
94
|
+
expect(root.getAttribute("aria-busy")).toBe("true");
|
|
95
|
+
expect(c.querySelector(".hk-spinner")).not.toBeNull();
|
|
96
|
+
// The status region must carry text content for screen readers.
|
|
97
|
+
const sr = c.querySelector(".hk-empty-sr-only");
|
|
98
|
+
expect(sr).not.toBeNull();
|
|
99
|
+
expect((sr as HTMLElement).textContent).toBe("Loading");
|
|
100
|
+
expect(c.querySelector(".hk-empty-title")).toBeNull();
|
|
101
|
+
expect(c.querySelector(".hk-empty-desc")).toBeNull();
|
|
102
|
+
expect(c.querySelector(".hk-empty-action")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("defaults to the page fit modifier", () => {
|
|
106
|
+
const c = mount(h(HkEmptyState, { title: "x" }));
|
|
107
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
108
|
+
expect(cls).toContain("hk-empty-state--page");
|
|
109
|
+
expect(cls).not.toContain("hk-empty-state--fill");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("maps fit=fill to the fill modifier", () => {
|
|
113
|
+
const c = mount(h(HkEmptyState, { title: "x", fit: "fill" }));
|
|
114
|
+
const cls = (c.querySelector(".hk-empty-state") as HTMLElement).className;
|
|
115
|
+
expect(cls).toContain("hk-empty-state--fill");
|
|
116
|
+
expect(cls).not.toContain("hk-empty-state--page");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,40 +1,78 @@
|
|
|
1
|
-
import { defineComponent } from "vue";
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
import HSpinner from "./HkSpinner";
|
|
3
5
|
import "./HkEmptyState.scss";
|
|
4
6
|
|
|
5
7
|
export default defineComponent({
|
|
6
8
|
name: "HkEmptyState",
|
|
7
9
|
props: {
|
|
8
|
-
|
|
10
|
+
/** Heading text. Optional — the loading variant renders without a title. */
|
|
11
|
+
title: { type: String, default: undefined },
|
|
9
12
|
description: { type: String, default: undefined },
|
|
13
|
+
/** Icon component (lucide-vue-next style); rendered via h() at size 32. */
|
|
14
|
+
icon: { type: Object as PropType<Component>, default: undefined },
|
|
15
|
+
/** Loading variant: only a centered spinner, exposed as a status region. */
|
|
16
|
+
loading: { type: Boolean, default: false },
|
|
17
|
+
/** page = bounded, centered card on desktop; fill = stretch to the host. */
|
|
18
|
+
fit: {
|
|
19
|
+
type: String as PropType<"page" | "fill">,
|
|
20
|
+
default: "page",
|
|
21
|
+
},
|
|
10
22
|
},
|
|
11
23
|
setup(props, { slots }) {
|
|
24
|
+
const { t } = useI18n();
|
|
12
25
|
return () => (
|
|
13
|
-
<div
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
<div
|
|
27
|
+
class={[
|
|
28
|
+
"hk-empty-state",
|
|
29
|
+
props.fit === "fill" ? "hk-empty-state--fill" : "hk-empty-state--page",
|
|
30
|
+
props.loading && "hk-empty-state--loading",
|
|
31
|
+
]}
|
|
32
|
+
role={props.loading ? "status" : undefined}
|
|
33
|
+
aria-busy={props.loading ? "true" : undefined}
|
|
34
|
+
>
|
|
35
|
+
{props.loading ? (
|
|
36
|
+
<>
|
|
37
|
+
{/* The status region needs text content, or screen readers
|
|
38
|
+
announce an empty live region when the spinner appears. */}
|
|
39
|
+
<span class="hk-empty-sr-only">
|
|
40
|
+
{t("hikari::emptyState.loading", "Loading")}
|
|
41
|
+
</span>
|
|
42
|
+
<HSpinner center />
|
|
43
|
+
</>
|
|
44
|
+
) : (
|
|
45
|
+
<>
|
|
46
|
+
<div class="hk-empty-icon">
|
|
47
|
+
{slots.icon ? (
|
|
48
|
+
slots.icon()
|
|
49
|
+
) : props.icon ? (
|
|
50
|
+
h(props.icon, { size: 32, "aria-hidden": true })
|
|
51
|
+
) : (
|
|
52
|
+
<svg
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
fill="none"
|
|
55
|
+
stroke="currentColor"
|
|
56
|
+
stroke-width="1.5"
|
|
57
|
+
stroke-linecap="round"
|
|
58
|
+
stroke-linejoin="round"
|
|
59
|
+
width="48"
|
|
60
|
+
height="48"
|
|
61
|
+
>
|
|
62
|
+
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
63
|
+
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
64
|
+
</svg>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
{props.title ? <p class="hk-empty-title">{props.title}</p> : null}
|
|
68
|
+
{props.description ? (
|
|
69
|
+
<p class="hk-empty-desc">{props.description}</p>
|
|
70
|
+
) : null}
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
{!props.loading && (
|
|
74
|
+
<div class="hk-empty-action">{slots.action?.()}</div>
|
|
75
|
+
)}
|
|
38
76
|
</div>
|
|
39
77
|
);
|
|
40
78
|
},
|