@celestia-island/hikari 0.55.79 → 0.55.81
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/components/HkAdminTablePage.test.tsx +93 -0
- package/src/components/HkAdminTablePage.tsx +36 -5
- package/src/components/HkCardList.scss +68 -0
- package/src/components/HkCardList.test.tsx +141 -0
- package/src/components/HkCardList.tsx +113 -0
- package/src/components/HkIconChip.scss +27 -0
- package/src/components/HkIconChip.test.tsx +56 -0
- package/src/components/HkIconChip.tsx +42 -0
- package/src/components/HkIconPropFunction.test.tsx +48 -0
- package/src/components/HkPageHeader.scss +19 -0
- package/src/components/HkPageHeader.test.ts +17 -0
- package/src/components/HkPageHeader.tsx +3 -1
- package/src/components/HkSectionHeader.scss +81 -0
- package/src/components/HkSectionHeader.test.tsx +70 -0
- package/src/components/HkSectionHeader.tsx +76 -0
- package/src/components/HkStatCard.scss +10 -0
- package/src/components/HkStatCard.tsx +7 -1
- package/src/components/HkStatCard.variants.test.tsx +32 -0
- package/src/components/HkSvgVarPresentation.test.tsx +9 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkAdminTablePage } from "./HkAdminTablePage";
|
|
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
|
+
const COLS = [{ key: "name", title: "Name" }];
|
|
25
|
+
|
|
26
|
+
describe("HkAdminTablePage", () => {
|
|
27
|
+
it("forwards title/subtitle/dense to the page header", () => {
|
|
28
|
+
const c = mount(
|
|
29
|
+
h(HkAdminTablePage, {
|
|
30
|
+
title: "Webhooks", subtitle: "Inbound endpoints", dense: true,
|
|
31
|
+
rows: [{ name: "a" }], columns: COLS,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(c.querySelector(".hk-page-header-title-text")?.textContent).toBe("Webhooks");
|
|
35
|
+
expect(c.querySelector(".hk-page-header-subtitle")?.textContent).toBe("Inbound endpoints");
|
|
36
|
+
expect((c.querySelector(".hk-page-header") as HTMLElement).className).toContain("hk-page-header-dense");
|
|
37
|
+
expect(c.querySelector("table")).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders the filter slot between header and table", () => {
|
|
41
|
+
const c = mount(
|
|
42
|
+
h(HkAdminTablePage, {
|
|
43
|
+
title: "T", rows: [{ name: "a" }], columns: COLS,
|
|
44
|
+
}, {
|
|
45
|
+
filter: () => h("div", { class: "probe-filter" }, "filter-chrome"),
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
const filter = c.querySelector(".hk-admin-table-page-filter .probe-filter");
|
|
49
|
+
expect(filter).toBeTruthy();
|
|
50
|
+
// filter sits above the table card, not inside it
|
|
51
|
+
expect(c.querySelector(".hk-card")?.contains(filter as Node)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("staleError renders a banner while rows stay on screen", () => {
|
|
55
|
+
const c = mount(
|
|
56
|
+
h(HkAdminTablePage, {
|
|
57
|
+
title: "T", staleError: "refresh failed", rows: [{ name: "a" }], columns: COLS,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
expect(c.textContent).toContain("refresh failed");
|
|
61
|
+
expect(c.querySelector("table")).toBeTruthy();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("hard error still replaces the table", () => {
|
|
65
|
+
const c = mount(
|
|
66
|
+
h(HkAdminTablePage, {
|
|
67
|
+
title: "T", error: "boom", rows: [], columns: COLS,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
expect(c.textContent).toContain("boom");
|
|
71
|
+
expect(c.querySelector("table")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("empty state carries the empty-action slot", () => {
|
|
75
|
+
const c = mount(
|
|
76
|
+
h(HkAdminTablePage, {
|
|
77
|
+
title: "T", rows: [], columns: COLS, emptyTitle: "Nothing here",
|
|
78
|
+
}, {
|
|
79
|
+
"empty-action": () => h("button", { class: "probe-retry" }, "Retry"),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(c.querySelector(".hk-empty-state")).toBeTruthy();
|
|
83
|
+
expect(c.querySelector(".probe-retry")).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("shows the spinner while loading with no rows yet", () => {
|
|
87
|
+
const c = mount(
|
|
88
|
+
h(HkAdminTablePage, { title: "T", loading: true, rows: [], columns: COLS }),
|
|
89
|
+
);
|
|
90
|
+
expect(c.querySelector(".hk-spinner-wrapper")).toBeTruthy();
|
|
91
|
+
expect(c.querySelector("table")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineComponent, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, type Component, type PropType } from "vue";
|
|
2
2
|
import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
|
|
3
3
|
import { HkPageHeader } from "./HkPageHeader";
|
|
4
4
|
|
|
@@ -13,19 +13,35 @@ export interface HTableColumn {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* CRUD table-page scaffold: HkPageHeader +
|
|
17
|
-
* empty state + HTable. Slots:
|
|
16
|
+
* CRUD table-page scaffold: HkPageHeader + (filter slot) + loading
|
|
17
|
+
* spinner + error alert + empty state + HTable. Slots:
|
|
18
18
|
* - `actions` — header actions (e.g. a "Create" button)
|
|
19
|
+
* - `filter` — filter chrome (HkFilterBar) between header and table
|
|
19
20
|
* - `cell-<key>` — per-column cell templates, forwarded to HTable
|
|
21
|
+
* - `empty-action` — action inside the empty state (Retry, …)
|
|
20
22
|
* - `create-modal` — create dialog, rendered after the table
|
|
21
23
|
* - `edit-modal` — edit dialog, rendered after the table
|
|
24
|
+
*
|
|
25
|
+
* Error model: `error` is the hard failure (no data — replaces the
|
|
26
|
+
* table), while `staleError` renders a banner ABOVE rows that are still
|
|
27
|
+
* on screen after a failed refresh, so a failed reload never hides data
|
|
28
|
+
* the user already had.
|
|
22
29
|
*/
|
|
23
30
|
export const HkAdminTablePage = defineComponent({
|
|
24
31
|
name: "HkAdminTablePage",
|
|
25
32
|
props: {
|
|
26
33
|
title: { type: String, default: "" },
|
|
34
|
+
/** Forwarded to the page header. */
|
|
35
|
+
subtitle: { type: String, default: undefined },
|
|
36
|
+
/** Forwarded to the page header. */
|
|
37
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
38
|
+
/** Compact page header variant. */
|
|
39
|
+
dense: { type: Boolean, default: false },
|
|
27
40
|
loading: { type: Boolean, default: false },
|
|
28
41
|
error: { type: String as PropType<string | undefined>, default: undefined },
|
|
42
|
+
/** Refresh failed while rows are still on screen — banner, not a
|
|
43
|
+
* replacement of the table. */
|
|
44
|
+
staleError: { type: String as PropType<string | undefined>, default: undefined },
|
|
29
45
|
rows: { type: Array as PropType<Record<string, unknown>[]>, required: true },
|
|
30
46
|
columns: { type: Array as PropType<HTableColumn[]>, required: true },
|
|
31
47
|
rowKey: { type: String, default: "id" },
|
|
@@ -37,10 +53,16 @@ export const HkAdminTablePage = defineComponent({
|
|
|
37
53
|
return () => {
|
|
38
54
|
const { t } = useI18n();
|
|
39
55
|
const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
|
|
56
|
+
// Extracted to an identifier: JSX children given as an inline object
|
|
57
|
+
// literal are not normalized to slots the way a bound one is (the
|
|
58
|
+
// chest retrySlots idiom this mirrors relies on the identifier form).
|
|
59
|
+
const emptySlots = slots["empty-action"]
|
|
60
|
+
? { action: () => slots["empty-action"]?.() }
|
|
61
|
+
: undefined;
|
|
40
62
|
return (
|
|
41
63
|
<div>
|
|
42
64
|
{props.title ? (
|
|
43
|
-
<HkPageHeader title={props.title}>
|
|
65
|
+
<HkPageHeader title={props.title} subtitle={props.subtitle} icon={props.icon} dense={props.dense}>
|
|
44
66
|
{{ actions: () => slots.actions?.() }}
|
|
45
67
|
</HkPageHeader>
|
|
46
68
|
) : slots.actions ? (
|
|
@@ -48,12 +70,21 @@ export const HkAdminTablePage = defineComponent({
|
|
|
48
70
|
{slots.actions()}
|
|
49
71
|
</div>
|
|
50
72
|
) : null}
|
|
73
|
+
{slots.filter ? <div class="hk-admin-table-page-filter">{slots.filter()}</div> : null}
|
|
74
|
+
|
|
75
|
+
{props.staleError ? <HAlert message={props.staleError} /> : null}
|
|
51
76
|
{props.error ? (
|
|
52
77
|
<HAlert message={props.error} />
|
|
53
78
|
) : props.loading && !props.rows.length ? (
|
|
54
79
|
<HSpinner center />
|
|
55
80
|
) : !props.rows.length ? (
|
|
56
|
-
<HEmptyState
|
|
81
|
+
<HEmptyState
|
|
82
|
+
boxed
|
|
83
|
+
title={emptyTitle}
|
|
84
|
+
description={props.emptyDescription}
|
|
85
|
+
>
|
|
86
|
+
{emptySlots}
|
|
87
|
+
</HEmptyState>
|
|
57
88
|
) : (
|
|
58
89
|
<HCard padded={false}>
|
|
59
90
|
<HTable columns={props.columns} rows={props.rows} rowKey={props.rowKey}>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* HkCardList / HkListRow — the standard card list anatomy. */
|
|
2
|
+
|
|
3
|
+
.hk-card-list {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
min-width: 0;
|
|
7
|
+
|
|
8
|
+
/* hairline dividers between rows (the CSS-native divide-y) */
|
|
9
|
+
> .hk-list-row + .hk-list-row {
|
|
10
|
+
border-top: 1px solid rgb(var(--color-border) / 10%);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.hk-list-row {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: var(--space-12, 0.75rem);
|
|
18
|
+
padding: var(--space-12, 0.75rem) var(--space-16, 1rem);
|
|
19
|
+
min-width: 0;
|
|
20
|
+
text-align: left;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-list-row-clickable {
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
transition: background-color var(--duration-normal, 0.3s) ease;
|
|
26
|
+
|
|
27
|
+
&:hover {
|
|
28
|
+
background: rgb(var(--color-text) / 4%);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&:focus-visible {
|
|
32
|
+
outline: 2px solid rgb(var(--color-primary));
|
|
33
|
+
outline-offset: -2px;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.hk-list-row-main {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
gap: 0.125rem;
|
|
41
|
+
min-width: 0;
|
|
42
|
+
flex: 1 1 auto;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.hk-list-row-title {
|
|
46
|
+
font-size: var(--text-sm, 0.875rem);
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
color: rgb(var(--color-text));
|
|
49
|
+
overflow: hidden;
|
|
50
|
+
text-overflow: ellipsis;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.hk-list-row-subtitle {
|
|
55
|
+
font-size: var(--text-xs, 0.75rem);
|
|
56
|
+
color: rgb(var(--color-muted));
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
text-overflow: ellipsis;
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hk-list-row-trailing {
|
|
63
|
+
margin-left: auto;
|
|
64
|
+
flex-shrink: 0;
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: var(--space-8, 0.5rem);
|
|
68
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, createCommentVNode, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkCardList, HkListRow } from "./HkCardList";
|
|
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
|
+
const FakeIcon = defineComponent({
|
|
25
|
+
name: "FakeIcon",
|
|
26
|
+
props: { size: { type: [Number, String], default: 24 } },
|
|
27
|
+
setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("HkCardList", () => {
|
|
31
|
+
it("renders rows inside an unpadded card list landmark", () => {
|
|
32
|
+
const c = mount(
|
|
33
|
+
h(HkCardList, () => h(HkListRow, { title: "A" })),
|
|
34
|
+
);
|
|
35
|
+
expect(c.querySelector(".hk-card")).toBeTruthy();
|
|
36
|
+
expect(c.querySelector(".hk-card-body-unpadded")).toBeTruthy();
|
|
37
|
+
const list = c.querySelector(".hk-card-list") as HTMLElement;
|
|
38
|
+
expect(list.getAttribute("role")).toBe("list");
|
|
39
|
+
expect(list.querySelectorAll(".hk-list-row")).toHaveLength(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders the empty slot instead of the list when provided without rows", () => {
|
|
43
|
+
const c = mount(h(HkCardList, null, { empty: () => h("p", { class: "probe-empty" }, "empty") }));
|
|
44
|
+
expect(c.querySelector(".hk-card-list")).toBeNull();
|
|
45
|
+
expect(c.querySelector(".probe-empty")?.textContent).toBe("empty");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to the empty slot when the default slot renders no rows (v-for idiom)", () => {
|
|
49
|
+
const rows: Array<{ name: string }> = [];
|
|
50
|
+
const c = mount(
|
|
51
|
+
h(HkCardList, null, {
|
|
52
|
+
default: () => rows.map((r) => h(HkListRow, { title: r.name })),
|
|
53
|
+
empty: () => h("p", { class: "probe-empty" }, "empty"),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
expect(c.querySelector(".hk-card-list")).toBeNull();
|
|
57
|
+
expect(c.querySelector(".probe-empty")?.textContent).toBe("empty");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to the empty slot when the default slot renders null (v-if idiom)", () => {
|
|
61
|
+
const c = mount(
|
|
62
|
+
h(HkCardList, null, {
|
|
63
|
+
default: () => null,
|
|
64
|
+
empty: () => h("p", { class: "probe-empty" }, "empty"),
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
expect(c.querySelector(".hk-card-list")).toBeNull();
|
|
68
|
+
expect(c.querySelector(".probe-empty")).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("falls back to the empty slot when the default slot is only a comment placeholder", () => {
|
|
72
|
+
const c = mount(
|
|
73
|
+
h(HkCardList, null, {
|
|
74
|
+
default: () => [createCommentVNode("v-if", true)],
|
|
75
|
+
empty: () => h("p", { class: "probe-empty" }, "empty"),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
expect(c.querySelector(".hk-card-list")).toBeNull();
|
|
79
|
+
expect(c.querySelector(".probe-empty")).toBeTruthy();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps the list when rows exist even with the empty slot present", () => {
|
|
83
|
+
const c = mount(
|
|
84
|
+
h(HkCardList, null, {
|
|
85
|
+
default: () => [h(HkListRow, { title: "A" })],
|
|
86
|
+
empty: () => h("p", { class: "probe-empty" }, "empty"),
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
expect(c.querySelector(".hk-card-list")).toBeTruthy();
|
|
90
|
+
expect(c.querySelectorAll(".hk-list-row")).toHaveLength(1);
|
|
91
|
+
expect(c.querySelector(".probe-empty")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("HkListRow", () => {
|
|
96
|
+
it("renders icon chip, title, subtitle and the trailing slot", () => {
|
|
97
|
+
const c = mount(
|
|
98
|
+
h(HkListRow, { icon: FakeIcon, iconTone: "success", title: "gpu-1", subtitle: "192.168.2.10" }, {
|
|
99
|
+
trailing: () => h("span", { class: "probe-badge" }, "online"),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
const row = c.querySelector(".hk-list-row") as HTMLElement;
|
|
103
|
+
expect(row.getAttribute("role")).toBe("listitem");
|
|
104
|
+
expect(row.querySelector(".hk-icon-chip-tone-success svg[data-fake-icon]")).toBeTruthy();
|
|
105
|
+
expect(row.querySelector(".hk-list-row-title")?.textContent).toBe("gpu-1");
|
|
106
|
+
expect(row.querySelector(".hk-list-row-subtitle")?.textContent).toBe("192.168.2.10");
|
|
107
|
+
expect(row.querySelector(".hk-list-row-trailing .probe-badge")?.textContent).toBe("online");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("keeps the default slot beside title/subtitle inside the main column", () => {
|
|
111
|
+
const c = mount(
|
|
112
|
+
h(HkListRow, { title: "T" }, { default: () => h("div", { class: "probe-extra" }, "extra") }),
|
|
113
|
+
);
|
|
114
|
+
expect(c.querySelector(".hk-list-row-main .probe-extra")).toBeTruthy();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("activates on Enter/Space when clickable, ignoring bubbled keys", async () => {
|
|
118
|
+
const clicks: string[] = [];
|
|
119
|
+
const c = mount(
|
|
120
|
+
h(HkListRow, {
|
|
121
|
+
title: "T",
|
|
122
|
+
clickable: true,
|
|
123
|
+
onClick: (e: MouseEvent | KeyboardEvent) => clicks.push("key" in e ? e.key : "mouse"),
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
const row = c.querySelector(".hk-list-row") as HTMLElement;
|
|
127
|
+
expect(row.getAttribute("tabindex")).toBe("0");
|
|
128
|
+
for (const key of ["Enter", " "]) {
|
|
129
|
+
row.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
|
130
|
+
await Promise.resolve();
|
|
131
|
+
}
|
|
132
|
+
expect(clicks).toEqual(["Enter", " "]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is not interactive without clickable", () => {
|
|
136
|
+
const c = mount(h(HkListRow, { title: "T" }));
|
|
137
|
+
const row = c.querySelector(".hk-list-row") as HTMLElement;
|
|
138
|
+
expect(row.className).not.toContain("hk-list-row-clickable");
|
|
139
|
+
expect(row.getAttribute("tabindex")).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Comment, defineComponent, type Component, type PropType, type VNode } from "vue";
|
|
2
|
+
|
|
3
|
+
import HkCard from "./HkCard";
|
|
4
|
+
import { HkIconChip } from "./HkIconChip";
|
|
5
|
+
import type { StatTone } from "./HkStatCard";
|
|
6
|
+
import "./HkCardList.scss";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a default slot actually rendered any row. Testing
|
|
10
|
+
* `slots.default` for truthiness is not enough: the canonical template
|
|
11
|
+
* idiom (`<HkListRow v-for>` plus `<template #empty>`) always provides a
|
|
12
|
+
* default slot whose v-for renders zero vnodes, and a `v-if` row renders
|
|
13
|
+
* a comment placeholder — both would otherwise show an empty bordered
|
|
14
|
+
* card where the empty state belongs.
|
|
15
|
+
*/
|
|
16
|
+
function renderedRows(nodes: unknown): boolean {
|
|
17
|
+
if (nodes == null || nodes === false || nodes === "") return false;
|
|
18
|
+
if (Array.isArray(nodes)) return nodes.some((n) => renderedRows(n));
|
|
19
|
+
if (typeof nodes === "object") return (nodes as VNode).type !== Comment;
|
|
20
|
+
return true; // non-empty text content
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* HkCardList — the standard card list: an unpadded HkCard whose body is
|
|
25
|
+
* a `role="list"` column of HkListRow entries separated by hairlines.
|
|
26
|
+
*
|
|
27
|
+
* This anatomy is what the console family calls "card rows, never raw
|
|
28
|
+
* ul/ol" (the host's UnoCSS setup has no preflight, so `<ul>` indents
|
|
29
|
+
* 40px): a bordered card surface, rows at `px-4 py-3` with a 12px gutter,
|
|
30
|
+
* hairline dividers between them. It was copy-pasted across ten views
|
|
31
|
+
* before becoming a component.
|
|
32
|
+
*
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <HkCardList>
|
|
35
|
+
* <HkListRow icon={Server} title={row.name} subtitle={row.url}
|
|
36
|
+
* trailing={() => <HkBadge>{row.status}</HkBadge>} />
|
|
37
|
+
* </HkCardList>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export const HkCardList = defineComponent({
|
|
41
|
+
name: "HkCardList",
|
|
42
|
+
setup(_props, { slots }) {
|
|
43
|
+
return () => {
|
|
44
|
+
const rows = slots.default?.();
|
|
45
|
+
return (
|
|
46
|
+
<HkCard padded={false}>
|
|
47
|
+
{slots.empty && !renderedRows(rows) ? (
|
|
48
|
+
slots.empty()
|
|
49
|
+
) : (
|
|
50
|
+
<div class="hk-card-list" role="list">
|
|
51
|
+
{rows}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</HkCard>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* HkListRow — one entry of an HkCardList: an optional tone-tinted icon
|
|
62
|
+
* chip, a main column (title / subtitle / free-form default slot), and a
|
|
63
|
+
* trailing slot pinned to the right edge (badges, switches, actions).
|
|
64
|
+
*
|
|
65
|
+
* The row is a list item, not a button — set `clickable` when the whole
|
|
66
|
+
* row drills through, which adds the cursor/focus/hover contract the
|
|
67
|
+
* stat cards use.
|
|
68
|
+
*/
|
|
69
|
+
export const HkListRow = defineComponent({
|
|
70
|
+
name: "HkListRow",
|
|
71
|
+
props: {
|
|
72
|
+
/** Leading icon — rendered through the standard HkIconChip. */
|
|
73
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
74
|
+
iconTone: { type: String as PropType<StatTone>, default: "primary" },
|
|
75
|
+
title: { type: String, default: undefined },
|
|
76
|
+
subtitle: { type: String, default: undefined },
|
|
77
|
+
/** Whole-row click (drill-through). */
|
|
78
|
+
clickable: { type: Boolean, default: false },
|
|
79
|
+
},
|
|
80
|
+
emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
|
|
81
|
+
setup(props, { emit, slots }) {
|
|
82
|
+
return () => (
|
|
83
|
+
<div
|
|
84
|
+
class={["hk-list-row", props.clickable && "hk-list-row-clickable"]}
|
|
85
|
+
role="listitem"
|
|
86
|
+
tabindex={props.clickable ? 0 : undefined}
|
|
87
|
+
onKeydown={
|
|
88
|
+
props.clickable
|
|
89
|
+
? (e: KeyboardEvent) => {
|
|
90
|
+
// role listitem + tabindex promises keyboard activation
|
|
91
|
+
// only when the row itself is made interactive; keys
|
|
92
|
+
// bubbling from focusable children are left alone.
|
|
93
|
+
if (e.target !== e.currentTarget) return;
|
|
94
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
emit("click", e);
|
|
97
|
+
}
|
|
98
|
+
: undefined
|
|
99
|
+
}
|
|
100
|
+
onClick={props.clickable ? (e: MouseEvent) => emit("click", e) : undefined}
|
|
101
|
+
|
|
102
|
+
>
|
|
103
|
+
{props.icon ? <HkIconChip icon={props.icon} tone={props.iconTone} /> : null}
|
|
104
|
+
<div class="hk-list-row-main">
|
|
105
|
+
{props.title ? <div class="hk-list-row-title">{props.title}</div> : null}
|
|
106
|
+
{props.subtitle ? <div class="hk-list-row-subtitle">{props.subtitle}</div> : null}
|
|
107
|
+
{slots.default?.()}
|
|
108
|
+
</div>
|
|
109
|
+
{slots.trailing ? <div class="hk-list-row-trailing">{slots.trailing()}</div> : null}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* HkIconChip — the standard leading icon chip.
|
|
2
|
+
* The tone rules mirror HkStatCard's chip-icon palette verbatim so the
|
|
3
|
+
* two never drift apart. */
|
|
4
|
+
.hk-icon-chip {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
justify-content: center;
|
|
8
|
+
border-radius: var(--radius-md, 0.5rem);
|
|
9
|
+
flex-shrink: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.hk-icon-chip-md {
|
|
13
|
+
width: var(--space-28, 1.75rem);
|
|
14
|
+
height: var(--space-28, 1.75rem);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.hk-icon-chip-sm {
|
|
18
|
+
width: var(--space-24, 1.5rem);
|
|
19
|
+
height: var(--space-24, 1.5rem);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.hk-icon-chip-tone-success { background: rgb(var(--color-success) / 12%); color: rgb(var(--color-success)); }
|
|
23
|
+
.hk-icon-chip-tone-warning { background: rgb(var(--color-warning) / 12%); color: rgb(var(--color-warning)); }
|
|
24
|
+
.hk-icon-chip-tone-error { background: rgb(var(--color-error) / 12%); color: rgb(var(--color-error)); }
|
|
25
|
+
.hk-icon-chip-tone-info { background: rgb(var(--color-info, rgb(var(--color-primary))) / 12%); color: rgb(var(--color-info, rgb(var(--color-primary)))); }
|
|
26
|
+
.hk-icon-chip-tone-primary { background: rgb(var(--color-primary) / 12%); color: rgb(var(--color-primary)); }
|
|
27
|
+
.hk-icon-chip-tone-muted { background: rgb(var(--color-muted) / 12%); color: rgb(var(--color-muted)); }
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkIconChip } from "./HkIconChip";
|
|
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
|
+
const FakeIcon = defineComponent({
|
|
25
|
+
name: "FakeIcon",
|
|
26
|
+
props: { size: { type: [Number, String], default: 24 } },
|
|
27
|
+
setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("HkIconChip", () => {
|
|
31
|
+
it("renders the icon at the md box size by default", () => {
|
|
32
|
+
const c = mount(h(HkIconChip, { icon: FakeIcon }));
|
|
33
|
+
const chip = c.querySelector(".hk-icon-chip") as HTMLElement;
|
|
34
|
+
expect(chip).toBeTruthy();
|
|
35
|
+
expect(chip.className).toContain("hk-icon-chip-md");
|
|
36
|
+
expect(chip.className).toContain("hk-icon-chip-tone-primary");
|
|
37
|
+
expect(chip.getAttribute("aria-hidden")).toBe("true");
|
|
38
|
+
expect(chip.querySelector("svg[data-fake-icon]")?.getAttribute("width")).toBe("15");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the sm box with a 13px glyph", () => {
|
|
42
|
+
const c = mount(h(HkIconChip, { icon: FakeIcon, size: "sm" }));
|
|
43
|
+
const chip = c.querySelector(".hk-icon-chip") as HTMLElement;
|
|
44
|
+
expect(chip.className).toContain("hk-icon-chip-sm");
|
|
45
|
+
expect(chip.querySelector("svg[data-fake-icon]")?.getAttribute("width")).toBe("13");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("carries the tone class for every palette entry", () => {
|
|
49
|
+
for (const tone of ["success", "warning", "error", "info", "primary", "muted"] as const) {
|
|
50
|
+
const c = mount(h(HkIconChip, { icon: FakeIcon, tone }));
|
|
51
|
+
expect((c.querySelector(".hk-icon-chip") as HTMLElement).className).toContain(
|
|
52
|
+
`hk-icon-chip-tone-${tone}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import type { StatTone } from "./HkStatCard";
|
|
4
|
+
import "./HkIconChip.scss";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HkIconChip — the standard leading icon chip: a small rounded square
|
|
8
|
+
* painted in a 10% tint of the tone color with the tone color as the
|
|
9
|
+
* glyph ink. This anatomy was copy-pasted as inline styles across the
|
|
10
|
+
* consumers (roster rows, card headers, stat strips) before it became a
|
|
11
|
+
* component; the tone palette is the one HkStatCard owns, so chips always
|
|
12
|
+
* match the stat card's health colors.
|
|
13
|
+
*
|
|
14
|
+
* Purely decorative by contract — the chip is aria-hidden and the icon
|
|
15
|
+
* meaning must be conveyed by adjacent text (row title, card label).
|
|
16
|
+
*
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <HkIconChip icon={Cpu} tone="info" />
|
|
19
|
+
* <HkIconChip icon={Coins} size="sm" />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export const HkIconChip = defineComponent({
|
|
23
|
+
name: "HkIconChip",
|
|
24
|
+
props: {
|
|
25
|
+
/** Icon component (lucide-vue-next style). */
|
|
26
|
+
icon: { type: [Object, Function] as PropType<Component>, required: true },
|
|
27
|
+
/** Tone drives both the tint background and the glyph ink. */
|
|
28
|
+
tone: { type: String as PropType<StatTone>, default: "primary" },
|
|
29
|
+
/** sm = 24px box / 13px glyph, md = 28px box / 15px glyph. */
|
|
30
|
+
size: { type: String as PropType<"sm" | "md">, default: "md" },
|
|
31
|
+
},
|
|
32
|
+
setup(props) {
|
|
33
|
+
return () => (
|
|
34
|
+
<span
|
|
35
|
+
class={["hk-icon-chip", `hk-icon-chip-${props.size}`, `hk-icon-chip-tone-${props.tone}`]}
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
>
|
|
38
|
+
{h(props.icon, { size: props.size === "sm" ? 13 : 15 })}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkIconChip } from "./HkIconChip";
|
|
5
|
+
import { HkPageHeader } from "./HkPageHeader";
|
|
6
|
+
import { HkSectionHeader } from "./HkSectionHeader";
|
|
7
|
+
import { HkStatCard } from "./HkStatCard";
|
|
8
|
+
|
|
9
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
10
|
+
|
|
11
|
+
function mount(node: ReturnType<typeof h>) {
|
|
12
|
+
const container = document.createElement("div");
|
|
13
|
+
document.body.appendChild(container);
|
|
14
|
+
const app = createApp({ render: () => node });
|
|
15
|
+
app.mount(container);
|
|
16
|
+
mounts.push({ app, container });
|
|
17
|
+
return container;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
22
|
+
app.unmount();
|
|
23
|
+
container.remove();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** A lucide-vue-next style icon: a plain functional component. */
|
|
28
|
+
const FnIcon = (_props: { size?: number }, _ctx: unknown) =>
|
|
29
|
+
h("svg", { "data-fn-icon": "" });
|
|
30
|
+
|
|
31
|
+
const FnIconComponent = defineComponent({
|
|
32
|
+
name: "FnIcon",
|
|
33
|
+
setup: () => () => h("svg", { "data-fn-icon": "" }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("icon props accept functional components", () => {
|
|
37
|
+
it("renders lucide-style functional icons without the Invalid-prop warning", () => {
|
|
38
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
39
|
+
mount(h(HkIconChip, { icon: FnIconComponent as never }));
|
|
40
|
+
mount(h(HkStatCard, { variant: "chip", value: 1, label: "L", icon: FnIconComponent as never }));
|
|
41
|
+
mount(h(HkPageHeader, { title: "T", icon: FnIconComponent as never }));
|
|
42
|
+
mount(h(HkSectionHeader, { title: "T", icon: FnIconComponent as never }));
|
|
43
|
+
const invalid = warn.mock.calls.filter((c) => String(c[0]).includes("Invalid prop"));
|
|
44
|
+
expect(invalid).toEqual([]);
|
|
45
|
+
expect(document.querySelectorAll("[data-fn-icon]")).toBeTruthy();
|
|
46
|
+
warn.mockRestore();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -78,3 +78,22 @@
|
|
|
78
78
|
height: var(--space-24, 1.5rem);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
/* lead — a back affordance (or any leading control) riding before the
|
|
83
|
+
* title inside the h1 box, keeping the heading semantics intact. */
|
|
84
|
+
.hk-page-header-lead {
|
|
85
|
+
display: inline-flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
flex-shrink: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* titleExtra — inline chips (status badges, mono ids) after the title
|
|
91
|
+
* text; baseline-aligned with the title, wrapping with it. */
|
|
92
|
+
.hk-page-header-title-extra {
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
gap: var(--space-8, 0.5rem);
|
|
96
|
+
font-size: var(--text-xs, 0.75rem);
|
|
97
|
+
font-weight: 400;
|
|
98
|
+
min-width: 0;
|
|
99
|
+
}
|
|
@@ -64,4 +64,21 @@ describe("HkPageHeader", () => {
|
|
|
64
64
|
"hk-page-header-dense",
|
|
65
65
|
);
|
|
66
66
|
});
|
|
67
|
+
it("renders the lead slot before the title icon", () => {
|
|
68
|
+
const c = mountHeader({ title: "T" }, { lead: () => h("button", { class: "probe-back" }, "Back") });
|
|
69
|
+
const title = c.querySelector(".hk-page-header-title")!;
|
|
70
|
+
expect(title.querySelector(".hk-page-header-lead .probe-back")).toBeTruthy();
|
|
71
|
+
const lead = c.querySelector(".hk-page-header-lead")!;
|
|
72
|
+
const icon = c.querySelector(".hk-page-header-icon");
|
|
73
|
+
if (icon) {
|
|
74
|
+
expect(lead.compareDocumentPosition(icon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders titleExtra inline after the title text", () => {
|
|
79
|
+
const c = mountHeader({ title: "T" }, { titleExtra: () => h("span", { class: "probe-chip" }, "stable") });
|
|
80
|
+
const extra = c.querySelector(".hk-page-header-title-text + .hk-page-header-title-extra") ??
|
|
81
|
+
c.querySelector(".hk-page-header-title-extra");
|
|
82
|
+
expect(extra?.textContent).toContain("stable");
|
|
83
|
+
});
|
|
67
84
|
});
|
|
@@ -22,7 +22,7 @@ export const HkPageHeader = defineComponent({
|
|
|
22
22
|
title: { type: String, required: true },
|
|
23
23
|
subtitle: { type: String, default: undefined },
|
|
24
24
|
/** Leading icon component (rendered before the title text). */
|
|
25
|
-
icon: { type: Object as PropType<Component>, default: undefined },
|
|
25
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
26
26
|
/** Compact variant for dense pages (smaller title, tighter spacing). */
|
|
27
27
|
dense: { type: Boolean, default: false },
|
|
28
28
|
},
|
|
@@ -31,12 +31,14 @@ export const HkPageHeader = defineComponent({
|
|
|
31
31
|
<div class={["hk-page-header", props.dense && "hk-page-header-dense"]}>
|
|
32
32
|
<div class="hk-page-header-main">
|
|
33
33
|
<h1 class="hk-page-header-title">
|
|
34
|
+
{slots.lead ? <span class="hk-page-header-lead">{slots.lead()}</span> : null}
|
|
34
35
|
{slots.icon
|
|
35
36
|
? <span class="hk-page-header-icon">{slots.icon()}</span>
|
|
36
37
|
: props.icon
|
|
37
38
|
? <span class="hk-page-header-icon">{h(props.icon)}</span>
|
|
38
39
|
: null}
|
|
39
40
|
<span class="hk-page-header-title-text">{props.title}</span>
|
|
41
|
+
{slots.titleExtra ? <span class="hk-page-header-title-extra">{slots.titleExtra()}</span> : null}
|
|
40
42
|
</h1>
|
|
41
43
|
{props.subtitle && (
|
|
42
44
|
<p class="hk-page-header-subtitle">{props.subtitle}</p>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* HkSectionHeader — the standard in-card / in-page section header. */
|
|
2
|
+
|
|
3
|
+
.hk-section-header {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: var(--space-4, 0.25rem);
|
|
7
|
+
min-width: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.hk-section-header-row {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: var(--space-8, 0.5rem);
|
|
14
|
+
min-width: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.hk-section-header-icon {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
color: rgb(var(--color-muted));
|
|
20
|
+
flex-shrink: 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hk-section-header-title {
|
|
24
|
+
margin: 0;
|
|
25
|
+
font-size: var(--text-sm, 0.875rem);
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
color: rgb(var(--color-text));
|
|
28
|
+
display: flex;
|
|
29
|
+
align-items: baseline;
|
|
30
|
+
gap: var(--space-8, 0.5rem);
|
|
31
|
+
min-width: 0;
|
|
32
|
+
overflow-wrap: anywhere;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.hk-section-header-count {
|
|
36
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
37
|
+
font-weight: 400;
|
|
38
|
+
color: rgb(var(--color-muted));
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.hk-section-header-actions {
|
|
43
|
+
margin-left: auto;
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: var(--space-8, 0.5rem);
|
|
47
|
+
flex-shrink: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.hk-section-header-description {
|
|
51
|
+
margin: 0;
|
|
52
|
+
font-size: var(--text-xs, 0.75rem);
|
|
53
|
+
color: rgb(var(--color-muted));
|
|
54
|
+
line-height: 1.4;
|
|
55
|
+
overflow-wrap: anywhere;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── dense: tighter rhythm for dense panels ─────────────────────────── */
|
|
59
|
+
.hk-section-header-dense {
|
|
60
|
+
gap: var(--space-2, 0.125rem);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ── micro: tiny uppercase eyebrow label ────────────────────────────── */
|
|
64
|
+
.hk-section-header-micro .hk-section-header-title {
|
|
65
|
+
font-size: var(--text-2xs, 0.625rem);
|
|
66
|
+
font-weight: 600;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
letter-spacing: 0.05em;
|
|
69
|
+
color: rgb(var(--color-muted));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* ── mono: mono-caption idiom for ledgers/detail rails ──────────────── */
|
|
73
|
+
.hk-section-header-mono .hk-section-header-title {
|
|
74
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
75
|
+
font-size: var(--text-xs, 0.75rem);
|
|
76
|
+
font-weight: 500;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.hk-section-header-mono .hk-section-header-count {
|
|
80
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
81
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkSectionHeader } from "./HkSectionHeader";
|
|
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
|
+
const FakeIcon = defineComponent({
|
|
25
|
+
name: "FakeIcon",
|
|
26
|
+
props: { size: { type: [Number, String], default: 24 } },
|
|
27
|
+
setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("HkSectionHeader", () => {
|
|
31
|
+
it("renders an h3 title by default with the count beside it", () => {
|
|
32
|
+
const c = mount(h(HkSectionHeader, { title: "Token Usage", count: "12 total" }));
|
|
33
|
+
const title = c.querySelector("h3.hk-section-header-title") as HTMLElement;
|
|
34
|
+
expect(title).toBeTruthy();
|
|
35
|
+
expect(title.textContent).toContain("Token Usage");
|
|
36
|
+
expect(c.querySelector(".hk-section-header-count")?.textContent).toBe("12 total");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("honors the level prop for the document outline", () => {
|
|
40
|
+
const c = mount(h(HkSectionHeader, { title: "T", level: "h2" }));
|
|
41
|
+
expect(c.querySelector("h2.hk-section-header-title")).toBeTruthy();
|
|
42
|
+
const c2 = mount(h(HkSectionHeader, { title: "T", level: "div" }));
|
|
43
|
+
expect(c2.querySelector("h2, h3")).toBeNull();
|
|
44
|
+
expect(c2.querySelector(".hk-section-header-title")).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders description under the row and actions right-aligned in the row", () => {
|
|
48
|
+
const c = mount(
|
|
49
|
+
h(HkSectionHeader, { title: "T", description: "one-liner" }, {
|
|
50
|
+
actions: () => h("button", { class: "probe-action" }, "Export"),
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
expect(c.querySelector(".hk-section-header-description")?.textContent).toBe("one-liner");
|
|
54
|
+
expect(c.querySelector(".hk-section-header-row .hk-section-header-actions .probe-action")).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the leading icon inside the row", () => {
|
|
58
|
+
const c = mount(h(HkSectionHeader, { title: "T", icon: FakeIcon }));
|
|
59
|
+
expect(c.querySelector(".hk-section-header-row .hk-section-header-icon svg[data-fake-icon]")).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("applies variant and dense classes", () => {
|
|
63
|
+
const c = mount(h(HkSectionHeader, { title: "T", variant: "micro", dense: true }));
|
|
64
|
+
const root = c.querySelector(".hk-section-header") as HTMLElement;
|
|
65
|
+
expect(root.className).toContain("hk-section-header-micro");
|
|
66
|
+
expect(root.className).toContain("hk-section-header-dense");
|
|
67
|
+
const c2 = mount(h(HkSectionHeader, { title: "T", variant: "mono" }));
|
|
68
|
+
expect((c2.querySelector(".hk-section-header") as HTMLElement).className).toContain("hk-section-header-mono");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
|
+
|
|
3
|
+
import "./HkSectionHeader.scss";
|
|
4
|
+
|
|
5
|
+
export type SectionHeaderVariant = "default" | "micro" | "mono";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* HkSectionHeader — the standard in-card / in-page section header.
|
|
9
|
+
*
|
|
10
|
+
* The audit that introduced this counted ~89 distinct hand-rolled
|
|
11
|
+
* treatments for the same anatomy across the consumers: a small
|
|
12
|
+
* semibold heading, an optional one-line description under (or beside)
|
|
13
|
+
* it, an optional right-aligned actions cluster, and occasionally a
|
|
14
|
+
* leading icon or a trailing count badge. One component replaces the
|
|
15
|
+
* family.
|
|
16
|
+
*
|
|
17
|
+
* Variants:
|
|
18
|
+
* - `default` — `text-sm font-semibold` heading (the dominant idiom).
|
|
19
|
+
* - `micro` — tiny uppercase eyebrow label for dense panels.
|
|
20
|
+
* - `mono` — mono-caption idiom used by ledgers/detail rails.
|
|
21
|
+
*
|
|
22
|
+
* ```tsx
|
|
23
|
+
* <HkSectionHeader title="Token Usage" description="Per model this range">
|
|
24
|
+
* {{ actions: () => <HkButton size="sm">Export</HkButton> }}
|
|
25
|
+
* </HkSectionHeader>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const HkSectionHeader = defineComponent({
|
|
29
|
+
name: "HkSectionHeader",
|
|
30
|
+
props: {
|
|
31
|
+
title: { type: String, required: true },
|
|
32
|
+
/** Heading element: h2/h3 keep document outline, div for visual-only. */
|
|
33
|
+
level: { type: String as PropType<"h2" | "h3" | "div">, default: "h3" },
|
|
34
|
+
/** One-line description rendered under the heading. */
|
|
35
|
+
description: { type: String, default: undefined },
|
|
36
|
+
/** Leading icon component. */
|
|
37
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
38
|
+
/** Trailing count badge (e.g. "12 total") rendered beside the title. */
|
|
39
|
+
count: { type: String, default: undefined },
|
|
40
|
+
variant: { type: String as PropType<SectionHeaderVariant>, default: "default" },
|
|
41
|
+
/** Tighter spacing for dense panels. */
|
|
42
|
+
dense: { type: Boolean, default: false },
|
|
43
|
+
},
|
|
44
|
+
setup(props, { slots }) {
|
|
45
|
+
return () => {
|
|
46
|
+
const heading = h(
|
|
47
|
+
props.level,
|
|
48
|
+
{ class: "hk-section-header-title" },
|
|
49
|
+
[
|
|
50
|
+
props.title,
|
|
51
|
+
props.count != null ? <span class="hk-section-header-count">{props.count}</span> : null,
|
|
52
|
+
],
|
|
53
|
+
);
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
class={[
|
|
57
|
+
"hk-section-header",
|
|
58
|
+
`hk-section-header-${props.variant}`,
|
|
59
|
+
props.dense && "hk-section-header-dense",
|
|
60
|
+
]}
|
|
61
|
+
>
|
|
62
|
+
<div class="hk-section-header-row">
|
|
63
|
+
{props.icon ? <span class="hk-section-header-icon">{h(props.icon, { size: 15 })}</span> : null}
|
|
64
|
+
{heading}
|
|
65
|
+
{slots.actions ? <div class="hk-section-header-actions">{slots.actions()}</div> : null}
|
|
66
|
+
</div>
|
|
67
|
+
{props.description ? (
|
|
68
|
+
<p class="hk-section-header-description">{props.description}</p>
|
|
69
|
+
) : slots.description ? (
|
|
70
|
+
<p class="hk-section-header-description">{slots.description()}</p>
|
|
71
|
+
) : null}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
});
|
|
@@ -187,3 +187,13 @@
|
|
|
187
187
|
.hk-stat-card-bar-detail {
|
|
188
188
|
max-width: 100%;
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
/* footer slot — free-form content row under the value/hint (badge rows,
|
|
192
|
+
* deltas, sparklines). Wraps; does not collapse like the hint. */
|
|
193
|
+
.hk-stat-card-footer {
|
|
194
|
+
display: flex;
|
|
195
|
+
flex-wrap: wrap;
|
|
196
|
+
align-items: center;
|
|
197
|
+
gap: var(--space-8, 0.5rem);
|
|
198
|
+
max-width: 100%;
|
|
199
|
+
}
|
|
@@ -15,6 +15,8 @@ export type StatTone = "success" | "warning" | "error" | "info" | "primary" | "m
|
|
|
15
15
|
* hint (the original anatomy, still the default).
|
|
16
16
|
* - `chip` — horizontal: a tone-tinted icon chip beside value + label,
|
|
17
17
|
* with an optional trailing `aside` slot (tier badge, delta, …).
|
|
18
|
+
* Every variant also takes a `footer` slot — a free-form content row
|
|
19
|
+
* under the value/hint for badge rows and deltas.
|
|
18
20
|
* - `ring` — a single utilization ring with the value inside, the label
|
|
19
21
|
* (with the icon) underneath, and the hint as the detail line.
|
|
20
22
|
* - `bar` — label + value on one row over a tone-colored progress bar,
|
|
@@ -75,7 +77,7 @@ export const HkStatCard = defineComponent({
|
|
|
75
77
|
/** Whole-card click (drill-through to the owning page). */
|
|
76
78
|
clickable: { type: Boolean, default: false },
|
|
77
79
|
/** chip/ring: leading icon component (lucide-vue-next style). */
|
|
78
|
-
icon: { type: Object as PropType<Component>, default: undefined },
|
|
80
|
+
icon: { type: [Object, Function] as PropType<Component>, default: undefined },
|
|
79
81
|
/** ring/bar: gauge percentage, clamped to 0–100. */
|
|
80
82
|
pct: { type: Number, default: undefined },
|
|
81
83
|
/** ring: sub-label rendered inside the ring under the value (the unit). */
|
|
@@ -126,6 +128,7 @@ export const HkStatCard = defineComponent({
|
|
|
126
128
|
{slots.aside && <div class="hk-stat-card-aside">{slots.aside()}</div>}
|
|
127
129
|
</div>
|
|
128
130
|
{props.hint && <div class="hk-stat-card-hint">{props.hint}</div>}
|
|
131
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
129
132
|
</div>
|
|
130
133
|
);
|
|
131
134
|
}
|
|
@@ -157,6 +160,7 @@ export const HkStatCard = defineComponent({
|
|
|
157
160
|
{props.hint}
|
|
158
161
|
</div>
|
|
159
162
|
)}
|
|
163
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
160
164
|
</div>
|
|
161
165
|
);
|
|
162
166
|
}
|
|
@@ -178,6 +182,7 @@ export const HkStatCard = defineComponent({
|
|
|
178
182
|
{props.hint}
|
|
179
183
|
</div>
|
|
180
184
|
)}
|
|
185
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
181
186
|
</div>
|
|
182
187
|
);
|
|
183
188
|
}
|
|
@@ -191,6 +196,7 @@ export const HkStatCard = defineComponent({
|
|
|
191
196
|
</div>
|
|
192
197
|
<div class="hk-stat-card-value">{props.value}</div>
|
|
193
198
|
{props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
|
|
199
|
+
{slots.footer && <div class="hk-stat-card-footer">{slots.footer()}</div>}
|
|
194
200
|
</div>
|
|
195
201
|
);
|
|
196
202
|
};
|
|
@@ -196,6 +196,38 @@ describe("HkStatCard bar", () => {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
+
describe("HkStatCard footer slot (all variants)", () => {
|
|
200
|
+
it("renders the footer content row under the value on plain", () => {
|
|
201
|
+
const c = mount(
|
|
202
|
+
h(HkStatCard, { label: "Total", value: "1.2M" }, { footer: () => h("span", { class: "probe-badge" }, "3 dims") }),
|
|
203
|
+
);
|
|
204
|
+
expect(c.querySelector(".hk-stat-card-footer .probe-badge")?.textContent).toBe("3 dims");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("renders the footer row after the detail line on ring and bar", () => {
|
|
208
|
+
const ring = mount(
|
|
209
|
+
h(HkStatCard, { variant: "ring", value: "40%", label: "L", pct: 40 }, { footer: () => h("i", { class: "probe-r" }) }),
|
|
210
|
+
);
|
|
211
|
+
expect(ring.querySelector(".hk-stat-card-footer .probe-r")).toBeTruthy();
|
|
212
|
+
const bar = mount(
|
|
213
|
+
h(HkStatCard, { variant: "bar", value: "72%", label: "L", pct: 72 }, { footer: () => h("i", { class: "probe-b" }) }),
|
|
214
|
+
);
|
|
215
|
+
expect(bar.querySelector(".hk-stat-card-footer .probe-b")).toBeTruthy();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("renders the footer row on the chip variant too", () => {
|
|
219
|
+
const c = mount(
|
|
220
|
+
h(HkStatCard, { variant: "chip", value: 3, label: "Running" }, { footer: () => h("i", { class: "probe-c" }) }),
|
|
221
|
+
);
|
|
222
|
+
expect(c.querySelector(".hk-stat-card-footer .probe-c")).toBeTruthy();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("omits the footer node when the slot is absent", () => {
|
|
226
|
+
const c = mount(h(HkStatCard, { label: "L", value: 1 }));
|
|
227
|
+
expect(c.querySelector(".hk-stat-card-footer")).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
199
231
|
describe("statToneColor", () => {
|
|
200
232
|
it("maps every tone to the theme-variable palette the card paints with", () => {
|
|
201
233
|
expect(statToneColor("success")).toBe("rgb(var(--color-success))");
|
|
@@ -162,6 +162,15 @@ describe("index export surface smoke", () => {
|
|
|
162
162
|
"HkEmptyState",
|
|
163
163
|
"HkPageHeader",
|
|
164
164
|
"HkTable",
|
|
165
|
+
"HkIconChip",
|
|
166
|
+
"HIconChip",
|
|
167
|
+
"HkCardList",
|
|
168
|
+
"HCardList",
|
|
169
|
+
"HkListRow",
|
|
170
|
+
"HListRow",
|
|
171
|
+
"HkSectionHeader",
|
|
172
|
+
"HSectionHeader",
|
|
173
|
+
"HkAdminTablePage",
|
|
165
174
|
] as const) {
|
|
166
175
|
expect(Hk, `missing export: ${name}`).toHaveProperty(name);
|
|
167
176
|
}
|
package/src/index.ts
CHANGED
|
@@ -754,6 +754,12 @@ export { HkStatCard as HkStatCard } from "./components/HkStatCard";
|
|
|
754
754
|
export { statToneColor, type StatTone as StatTone, type StatVariant as StatVariant } from "./components/HkStatCard";
|
|
755
755
|
export { HkStatusPill as HkStatusPill } from "./components/HkStatusPill";
|
|
756
756
|
export { default as HkFilterBar } from "./components/HkFilterBar";
|
|
757
|
+
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
758
|
+
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
759
|
+
export { HkCardList, HkListRow } from "./components/HkCardList";
|
|
760
|
+
export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
|
|
761
|
+
export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
|
|
762
|
+
export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
|
|
757
763
|
export { default as HFilterBar } from "./components/HkFilterBar";
|
|
758
764
|
export { HkShareBar as HkShareBar } from "./components/HkShareBar";
|
|
759
765
|
export { HkSecretRevealModal as HkSecretRevealModal } from "./components/HkSecretRevealModal";
|