@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
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
- <HEmptyState
364
- title="No items"
365
- description="Nothing to show here yet."
366
- v-slots={{ icon: () => <HIcon name="inbox" size={48} /> }}
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
- title: { type: String, required: true },
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 class="hk-empty-state">
14
- <div class="hk-empty-icon">
15
- {slots.icon ? (
16
- slots.icon()
17
- ) : (
18
- <svg
19
- viewBox="0 0 24 24"
20
- fill="none"
21
- stroke="currentColor"
22
- stroke-width="1.5"
23
- stroke-linecap="round"
24
- stroke-linejoin="round"
25
- width="48"
26
- height="48"
27
- >
28
- <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
29
- <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" />
30
- </svg>
31
- )}
32
- </div>
33
- <p class="hk-empty-title">{props.title}</p>
34
- {props.description ? (
35
- <p class="hk-empty-desc">{props.description}</p>
36
- ) : null}
37
- <div class="hk-empty-action">{slots.action?.()}</div>
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
  },