@celestia-island/hikari 0.55.78 → 0.55.80
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 +35 -4
- package/src/components/HkBoard.tsx +6 -0
- 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/HkMinimap.tsx +3 -0
- package/src/components/HkNodeCanvas.tsx +30 -21
- package/src/components/HkPageHeader.scss +19 -0
- package/src/components/HkPageHeader.test.ts +17 -0
- package/src/components/HkPageHeader.tsx +2 -0
- 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 +6 -0
- package/src/components/HkStatCard.variants.test.tsx +32 -0
- package/src/components/HkSvgVarPresentation.test.tsx +179 -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
|
+
});
|
|
@@ -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, 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}>
|
|
@@ -98,6 +98,11 @@ export interface BoardEdgeInput {
|
|
|
98
98
|
width?: number;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Ink colors ride INLINE STYLES beside the stroke presentation attribute:
|
|
102
|
+
// var() references in a presentation attribute are not substituted on every
|
|
103
|
+
// engine (Firefox drops the whole declaration → stroke:none → invisible
|
|
104
|
+
// edges; svgwg#1031, 2025-11: browsers still disagree). Same contract as
|
|
105
|
+
// HkGaugeRing/HkProgressRing.
|
|
101
106
|
const DEFAULT_INK = "rgb(var(--color-primary) / 38%)";
|
|
102
107
|
|
|
103
108
|
export default defineComponent({
|
|
@@ -426,6 +431,7 @@ export default defineComponent({
|
|
|
426
431
|
class="hk-board-edge"
|
|
427
432
|
d={e.d}
|
|
428
433
|
stroke={e.ink}
|
|
434
|
+
style={{ stroke: e.ink }}
|
|
429
435
|
stroke-width={e.width}
|
|
430
436
|
stroke-dasharray={e.dashed ? "6 5" : undefined}
|
|
431
437
|
fill="none"
|
|
@@ -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 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 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
|
+
});
|
|
@@ -269,6 +269,7 @@ export default defineComponent({
|
|
|
269
269
|
height={b.bounds.h * s}
|
|
270
270
|
rx={2}
|
|
271
271
|
fill={b.color}
|
|
272
|
+
style={{ fill: b.color, stroke: b.color }}
|
|
272
273
|
opacity="0.28"
|
|
273
274
|
stroke={b.color}
|
|
274
275
|
stroke-width="0.6"
|
|
@@ -305,6 +306,7 @@ export default defineComponent({
|
|
|
305
306
|
cy={hubP[1]}
|
|
306
307
|
r={3}
|
|
307
308
|
fill="rgb(var(--color-primary))"
|
|
309
|
+
style={{ fill: "rgb(var(--color-primary))" }}
|
|
308
310
|
filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
|
|
309
311
|
/>
|
|
310
312
|
)}
|
|
@@ -315,6 +317,7 @@ export default defineComponent({
|
|
|
315
317
|
height={Math.max(1, vr.h)}
|
|
316
318
|
fill="none"
|
|
317
319
|
stroke="rgb(var(--color-primary))"
|
|
320
|
+
style={{ stroke: "rgb(var(--color-primary))" }}
|
|
318
321
|
stroke-width="1"
|
|
319
322
|
stroke-dasharray="3 2"
|
|
320
323
|
rx="2"
|