@celestia-island/hikari 0.55.76 → 0.55.78
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/HkFilterBar.scss +59 -0
- package/src/components/HkFilterBar.test.tsx +130 -0
- package/src/components/HkFilterBar.tsx +143 -0
- package/src/components/HkGaugeRing.tsx +13 -1
- package/src/components/HkSelectionGrid.scss +91 -0
- package/src/components/HkSelectionGrid.test.tsx +68 -0
- package/src/components/HkSelectionGrid.tsx +56 -1
- package/src/components/HkSettingsDialog.scss +12 -1
- package/src/components/HkStatCard.scss +107 -0
- package/src/components/HkStatCard.tsx +173 -21
- package/src/components/HkStatCard.variants.test.tsx +210 -0
- package/src/components/HkStepGuide.scss +177 -0
- package/src/components/HkStepGuide.test.tsx +81 -0
- package/src/components/HkStepGuide.tsx +129 -0
- package/src/components/HkThemeToggle.test.ts +23 -0
- package/src/components/HkThemeToggle.tsx +11 -2
- package/src/i18n/locales/ar/components.json +5 -1
- package/src/i18n/locales/de/components.json +5 -1
- package/src/i18n/locales/en/components.json +5 -1
- package/src/i18n/locales/es/components.json +5 -1
- package/src/i18n/locales/fr/components.json +5 -1
- package/src/i18n/locales/ja/components.json +5 -1
- package/src/i18n/locales/ko/components.json +5 -1
- package/src/i18n/locales/pt/components.json +5 -1
- package/src/i18n/locales/ru/components.json +5 -1
- package/src/i18n/locales/zh-Hans/components.json +5 -1
- package/src/i18n/locales/zh-Hant/components.json +5 -1
- package/src/index.ts +6 -1
- package/src/styles/admin-tokens.scss +21 -0
- package/src/theme/presets.ts +7 -0
|
@@ -80,3 +80,110 @@
|
|
|
80
80
|
text-overflow: ellipsis;
|
|
81
81
|
white-space: nowrap;
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/* ── chip variant ──────────────────────────────────────────────────────
|
|
85
|
+
Horizontal: tone-tinted icon chip, value over label, optional trailing
|
|
86
|
+
aside (tier badge / delta). The icon chip carries the tone color the
|
|
87
|
+
same way the plain variant's dot does — tint on the chip only. */
|
|
88
|
+
.hk-stat-card-chip-body {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
gap: var(--space-8, 0.5rem);
|
|
92
|
+
min-width: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.hk-stat-card-chip-icon {
|
|
96
|
+
display: inline-flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: center;
|
|
99
|
+
width: var(--space-28, 1.75rem);
|
|
100
|
+
height: var(--space-28, 1.75rem);
|
|
101
|
+
border-radius: var(--radius-sm, 0.375rem);
|
|
102
|
+
flex-shrink: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.hk-stat-card-chip-icon-success { background: rgb(var(--color-success) / 12%); color: rgb(var(--color-success)); }
|
|
106
|
+
.hk-stat-card-chip-icon-warning { background: rgb(var(--color-warning) / 12%); color: rgb(var(--color-warning)); }
|
|
107
|
+
.hk-stat-card-chip-icon-error { background: rgb(var(--color-error) / 12%); color: rgb(var(--color-error)); }
|
|
108
|
+
.hk-stat-card-chip-icon-info { background: rgb(var(--color-info, rgb(var(--color-primary))) / 12%); color: rgb(var(--color-info, rgb(var(--color-primary)))); }
|
|
109
|
+
.hk-stat-card-chip-icon-primary { background: rgb(var(--color-primary) / 12%); color: rgb(var(--color-primary)); }
|
|
110
|
+
.hk-stat-card-chip-icon-muted { background: rgb(var(--color-muted) / 12%); color: rgb(var(--color-muted)); }
|
|
111
|
+
|
|
112
|
+
.hk-stat-card-chip-main {
|
|
113
|
+
min-width: 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.hk-stat-card-chip-value {
|
|
117
|
+
font-size: var(--text-lg, 1.125rem);
|
|
118
|
+
font-weight: 700;
|
|
119
|
+
line-height: 1.35;
|
|
120
|
+
color: rgb(var(--color-text));
|
|
121
|
+
overflow: hidden;
|
|
122
|
+
text-overflow: ellipsis;
|
|
123
|
+
white-space: nowrap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.hk-stat-card-chip-label {
|
|
127
|
+
font-size: var(--text-xs, 0.75rem);
|
|
128
|
+
color: rgb(var(--color-muted));
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-overflow: ellipsis;
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hk-stat-card-aside {
|
|
135
|
+
margin-left: auto;
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* ── ring variant ──────────────────────────────────────────────────────
|
|
140
|
+
Centered utilization ring; the label (with the icon) sits under the
|
|
141
|
+
ring and the hint becomes the detail line. */
|
|
142
|
+
.hk-stat-card-ring {
|
|
143
|
+
align-items: center;
|
|
144
|
+
text-align: center;
|
|
145
|
+
gap: 0.375rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.hk-stat-card-ring-label {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: var(--space-4, 0.25rem);
|
|
152
|
+
max-width: 100%;
|
|
153
|
+
font-size: var(--text-xs, 0.75rem);
|
|
154
|
+
font-weight: 500;
|
|
155
|
+
color: rgb(var(--color-text));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.hk-stat-card-ring-label-text {
|
|
159
|
+
overflow: hidden;
|
|
160
|
+
text-overflow: ellipsis;
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.hk-stat-card-ring-detail {
|
|
165
|
+
max-width: 100%;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── bar variant ───────────────────────────────────────────────────────
|
|
169
|
+
Label + value on one row over a tone-colored bar. */
|
|
170
|
+
.hk-stat-card-bar-head {
|
|
171
|
+
display: flex;
|
|
172
|
+
align-items: baseline;
|
|
173
|
+
justify-content: space-between;
|
|
174
|
+
gap: var(--space-8, 0.5rem);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.hk-stat-card-bar-value {
|
|
178
|
+
font-size: var(--text-sm, 0.875rem);
|
|
179
|
+
font-weight: 700;
|
|
180
|
+
font-variant-numeric: tabular-nums;
|
|
181
|
+
color: rgb(var(--color-text));
|
|
182
|
+
overflow: hidden;
|
|
183
|
+
text-overflow: ellipsis;
|
|
184
|
+
white-space: nowrap;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.hk-stat-card-bar-detail {
|
|
188
|
+
max-width: 100%;
|
|
189
|
+
}
|
|
@@ -1,46 +1,198 @@
|
|
|
1
|
-
import { defineComponent, type PropType } from "vue";
|
|
1
|
+
import { defineComponent, h, type Component, type PropType } from "vue";
|
|
2
2
|
|
|
3
|
+
import HkGaugeRing from "./HkGaugeRing";
|
|
4
|
+
import HkProgressBar from "./HkProgressBar";
|
|
3
5
|
import "./HkStatCard.scss";
|
|
4
6
|
|
|
5
7
|
export type StatTone = "success" | "warning" | "error" | "info" | "primary" | "muted";
|
|
6
8
|
|
|
9
|
+
/**
|
|
10
|
+
* The standard KPI-cell shapes. Every variant shares the card surface
|
|
11
|
+
* (border, radius, background) and the tone palette; they differ only in
|
|
12
|
+
* how the number is presented:
|
|
13
|
+
*
|
|
14
|
+
* - `plain` — text-forward: label row with a tone dot, big value, optional
|
|
15
|
+
* hint (the original anatomy, still the default).
|
|
16
|
+
* - `chip` — horizontal: a tone-tinted icon chip beside value + label,
|
|
17
|
+
* with an optional trailing `aside` slot (tier badge, delta, …).
|
|
18
|
+
* - `ring` — a single utilization ring with the value inside, the label
|
|
19
|
+
* (with the icon) underneath, and the hint as the detail line.
|
|
20
|
+
* - `bar` — label + value on one row over a tone-colored progress bar,
|
|
21
|
+
* with the hint as the detail line.
|
|
22
|
+
*/
|
|
23
|
+
export type StatVariant = "plain" | "chip" | "ring" | "bar";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Tone → theme-variable CSS color (the exact palette the card paints
|
|
27
|
+
* itself with). Exported so hosts can match sibling chrome — badge
|
|
28
|
+
* variants, inline rings, legend dots — to the card's tone without
|
|
29
|
+
* re-declaring the mapping (and drifting from it).
|
|
30
|
+
*/
|
|
31
|
+
export function statToneColor(tone: StatTone): string {
|
|
32
|
+
switch (tone) {
|
|
33
|
+
case "success":
|
|
34
|
+
return "rgb(var(--color-success))";
|
|
35
|
+
case "warning":
|
|
36
|
+
return "rgb(var(--color-warning))";
|
|
37
|
+
case "error":
|
|
38
|
+
return "rgb(var(--color-error))";
|
|
39
|
+
case "info":
|
|
40
|
+
return "rgb(var(--color-info, rgb(var(--color-primary))))";
|
|
41
|
+
case "primary":
|
|
42
|
+
return "rgb(var(--color-primary))";
|
|
43
|
+
case "muted":
|
|
44
|
+
return "rgb(var(--color-muted))";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const clampPct = (pct: number | undefined): number =>
|
|
49
|
+
pct == null ? 0 : Math.min(100, Math.max(0, pct));
|
|
50
|
+
|
|
7
51
|
/**
|
|
8
52
|
* HkStatCard — one KPI cell for the admin stat grids.
|
|
9
53
|
*
|
|
10
|
-
* Anatomy: label + value + optional hint, with a
|
|
11
|
-
* health semantics (the text hue never changes —
|
|
12
|
-
* the dot alone, matching the interaction-state
|
|
54
|
+
* Anatomy (plain, the default): label + value + optional hint, with a
|
|
55
|
+
* tone dot carrying the health semantics (the text hue never changes —
|
|
56
|
+
* tone is expressed by the dot alone, matching the interaction-state
|
|
57
|
+
* precedence rules).
|
|
13
58
|
*
|
|
14
59
|
* ```tsx
|
|
15
|
-
* <
|
|
60
|
+
* <HkStatCard label="Hub health" value="2/2" tone="success" hint="entelecheia / arona" />
|
|
61
|
+
* <HkStatCard variant="chip" icon={Cpu} tone="info" label="Running" value={3} />
|
|
62
|
+
* <HkStatCard variant="ring" tone="success" pct={37.5} value="38%" unit="cores"
|
|
63
|
+
* icon={Cpu} label="Cores" hint="12 / 32 · monthly" />
|
|
64
|
+
* <HkStatCard variant="bar" tone="warning" pct={72} label="Storage" value="72%" hint="47 / 65 GB" />
|
|
16
65
|
* ```
|
|
17
66
|
*/
|
|
18
67
|
export const HkStatCard = defineComponent({
|
|
19
68
|
name: "HkStatCard",
|
|
20
69
|
props: {
|
|
70
|
+
variant: { type: String as PropType<StatVariant>, default: "plain" },
|
|
21
71
|
label: { type: String, required: true },
|
|
22
72
|
value: { type: [String, Number], required: true },
|
|
23
73
|
tone: { type: String as PropType<StatTone>, default: "muted" },
|
|
24
74
|
hint: { type: String, default: undefined },
|
|
25
75
|
/** Whole-card click (drill-through to the owning page). */
|
|
26
76
|
clickable: { type: Boolean, default: false },
|
|
77
|
+
/** chip/ring: leading icon component (lucide-vue-next style). */
|
|
78
|
+
icon: { type: Object as PropType<Component>, default: undefined },
|
|
79
|
+
/** ring/bar: gauge percentage, clamped to 0–100. */
|
|
80
|
+
pct: { type: Number, default: undefined },
|
|
81
|
+
/** ring: sub-label rendered inside the ring under the value (the unit). */
|
|
82
|
+
unit: { type: String, default: undefined },
|
|
27
83
|
},
|
|
28
|
-
emits: { click: (_e: MouseEvent) => true },
|
|
29
|
-
setup(props, { emit }) {
|
|
30
|
-
return () =>
|
|
31
|
-
|
|
32
|
-
class
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
84
|
+
emits: { click: (_e: MouseEvent | KeyboardEvent) => true },
|
|
85
|
+
setup(props, { emit, slots }) {
|
|
86
|
+
return () => {
|
|
87
|
+
const shared = {
|
|
88
|
+
class: [
|
|
89
|
+
"hk-stat-card",
|
|
90
|
+
`hk-stat-card-${props.variant}`,
|
|
91
|
+
props.clickable && "hk-stat-card-clickable",
|
|
92
|
+
],
|
|
93
|
+
role: props.clickable ? "button" : undefined,
|
|
94
|
+
tabindex: props.clickable ? 0 : undefined,
|
|
95
|
+
onClick: props.clickable ? (e: MouseEvent) => emit("click", e) : undefined,
|
|
96
|
+
// role="button" promises keyboard activation (Enter/Space) — the
|
|
97
|
+
// same contract HkContextRing's clickable ring honors. Key events
|
|
98
|
+
// bubbling from a focusable INSIDE the card (an aside button) are
|
|
99
|
+
// left alone so they don't double-fire the card's click.
|
|
100
|
+
onKeydown: props.clickable
|
|
101
|
+
? (e: KeyboardEvent) => {
|
|
102
|
+
if (e.target !== e.currentTarget) return;
|
|
103
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
emit("click", e);
|
|
106
|
+
}
|
|
107
|
+
: undefined,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (props.variant === "chip") {
|
|
111
|
+
return (
|
|
112
|
+
<div {...shared}>
|
|
113
|
+
<div class="hk-stat-card-chip-body">
|
|
114
|
+
{props.icon && (
|
|
115
|
+
<span
|
|
116
|
+
class={`hk-stat-card-chip-icon hk-stat-card-chip-icon-${props.tone}`}
|
|
117
|
+
aria-hidden="true"
|
|
118
|
+
>
|
|
119
|
+
{h(props.icon, { size: 16 })}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
<div class="hk-stat-card-chip-main">
|
|
123
|
+
<div class="hk-stat-card-chip-value">{props.value}</div>
|
|
124
|
+
<div class="hk-stat-card-chip-label">{props.label}</div>
|
|
125
|
+
</div>
|
|
126
|
+
{slots.aside && <div class="hk-stat-card-aside">{slots.aside()}</div>}
|
|
127
|
+
</div>
|
|
128
|
+
{props.hint && <div class="hk-stat-card-hint">{props.hint}</div>}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (props.variant === "ring") {
|
|
134
|
+
return (
|
|
135
|
+
<div {...shared}>
|
|
136
|
+
<HkGaugeRing
|
|
137
|
+
size={104}
|
|
138
|
+
strokeWidth={7}
|
|
139
|
+
gap={4}
|
|
140
|
+
animate={false}
|
|
141
|
+
rings={[
|
|
142
|
+
{
|
|
143
|
+
pct: clampPct(props.pct),
|
|
144
|
+
color: statToneColor(props.tone),
|
|
145
|
+
trackColor: "rgb(var(--color-text) / 8%)",
|
|
146
|
+
},
|
|
147
|
+
]}
|
|
148
|
+
centerValue={String(props.value)}
|
|
149
|
+
centerLabel={props.unit ?? ""}
|
|
150
|
+
/>
|
|
151
|
+
<div class="hk-stat-card-ring-label">
|
|
152
|
+
{props.icon && h(props.icon, { size: 13, "aria-hidden": true })}
|
|
153
|
+
<span class="hk-stat-card-ring-label-text">{props.label}</span>
|
|
154
|
+
</div>
|
|
155
|
+
{props.hint && (
|
|
156
|
+
<div class="hk-stat-card-hint hk-stat-card-ring-detail" title={props.hint}>
|
|
157
|
+
{props.hint}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (props.variant === "bar") {
|
|
165
|
+
return (
|
|
166
|
+
<div {...shared}>
|
|
167
|
+
<div class="hk-stat-card-bar-head">
|
|
168
|
+
<span class="hk-stat-card-label">{props.label}</span>
|
|
169
|
+
<span class="hk-stat-card-bar-value">{props.value}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<HkProgressBar
|
|
172
|
+
max={100}
|
|
173
|
+
size="xs"
|
|
174
|
+
segments={[{ value: clampPct(props.pct), color: statToneColor(props.tone) }]}
|
|
175
|
+
/>
|
|
176
|
+
{props.hint && (
|
|
177
|
+
<div class="hk-stat-card-hint hk-stat-card-bar-detail" title={props.hint}>
|
|
178
|
+
{props.hint}
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// plain — the original anatomy, unchanged.
|
|
186
|
+
return (
|
|
187
|
+
<div {...shared}>
|
|
188
|
+
<div class="hk-stat-card-head">
|
|
189
|
+
<span class="hk-stat-card-label">{props.label}</span>
|
|
190
|
+
<span class={`hk-stat-card-dot hk-stat-card-dot-${props.tone}`} aria-hidden="true" />
|
|
191
|
+
</div>
|
|
192
|
+
<div class="hk-stat-card-value">{props.value}</div>
|
|
193
|
+
{props.hint && <div class="hk-stat-card-hint" title={props.hint}>{props.hint}</div>}
|
|
40
194
|
</div>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</div>
|
|
44
|
-
);
|
|
195
|
+
);
|
|
196
|
+
};
|
|
45
197
|
},
|
|
46
198
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, defineComponent, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkStatCard, statToneColor } from "./HkStatCard";
|
|
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
|
+
/** A stand-in lucide-style icon component (functional, size prop). */
|
|
25
|
+
const FakeIcon = defineComponent({
|
|
26
|
+
name: "FakeIcon",
|
|
27
|
+
props: { size: { type: [Number, String], default: 24 } },
|
|
28
|
+
setup: (props) => () => h("svg", { "data-fake-icon": "", width: props.size, height: props.size }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("HkStatCard plain (default) — the original anatomy is preserved", () => {
|
|
32
|
+
it("renders the label/dot/value/hint anatomy without a variant class", () => {
|
|
33
|
+
const c = mount(h(HkStatCard, { label: "Hub health", value: "2/2", tone: "success", hint: "hubs" }));
|
|
34
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
35
|
+
expect(card.className).toContain("hk-stat-card-plain");
|
|
36
|
+
expect(c.querySelector(".hk-stat-card-head")).toBeTruthy();
|
|
37
|
+
expect(c.querySelector(".hk-stat-card-dot-success")).toBeTruthy();
|
|
38
|
+
expect(c.querySelector(".hk-stat-card-value")?.textContent).toBe("2/2");
|
|
39
|
+
expect(c.querySelector(".hk-stat-card-hint")?.textContent).toBe("hubs");
|
|
40
|
+
// plain renders no chip/ring/bar anatomy
|
|
41
|
+
expect(c.querySelector(".hk-stat-card-chip-body")).toBeNull();
|
|
42
|
+
expect(c.querySelector(".hk-gauge-ring")).toBeNull();
|
|
43
|
+
expect(c.querySelector(".hk-progress-bar")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("HkStatCard chip", () => {
|
|
48
|
+
it("renders the tinted icon chip beside value-over-label", () => {
|
|
49
|
+
const c = mount(
|
|
50
|
+
h(HkStatCard, { variant: "chip", icon: FakeIcon, tone: "info", label: "Running", value: 3 }),
|
|
51
|
+
);
|
|
52
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
53
|
+
expect(card.className).toContain("hk-stat-card-chip");
|
|
54
|
+
const chip = c.querySelector(".hk-stat-card-chip-icon") as HTMLElement;
|
|
55
|
+
expect(chip).toBeTruthy();
|
|
56
|
+
expect(chip.className).toContain("hk-stat-card-chip-icon-info");
|
|
57
|
+
expect(chip.querySelector("svg[data-fake-icon]")).toBeTruthy();
|
|
58
|
+
expect(c.querySelector(".hk-stat-card-chip-value")?.textContent).toBe("3");
|
|
59
|
+
expect(c.querySelector(".hk-stat-card-chip-label")?.textContent).toBe("Running");
|
|
60
|
+
// no tone dot on the chip variant — the chip itself carries the tone
|
|
61
|
+
expect(c.querySelector(".hk-stat-card-dot")).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("omits the icon chip node when no icon is given", () => {
|
|
65
|
+
const c = mount(h(HkStatCard, { variant: "chip", label: "L", value: 1 }));
|
|
66
|
+
expect(c.querySelector(".hk-stat-card-chip-icon")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders the aside slot at the trailing edge", () => {
|
|
70
|
+
const c = mount(
|
|
71
|
+
h(HkStatCard, { variant: "chip", label: "Balance", value: 20 }, { aside: () => h("span", { class: "aside-probe" }, "basic") }),
|
|
72
|
+
);
|
|
73
|
+
expect(c.querySelector(".hk-stat-card-aside .aside-probe")?.textContent).toBe("basic");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps the whole-card click affordance", () => {
|
|
77
|
+
const c = mount(h(HkStatCard, { variant: "chip", label: "L", value: 1, clickable: true }));
|
|
78
|
+
const el = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
79
|
+
expect(el.getAttribute("role")).toBe("button");
|
|
80
|
+
expect(el.className).toContain("hk-stat-card-clickable");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("HkStatCard ring", () => {
|
|
85
|
+
it("renders one tone-colored ring with the value and unit inside", () => {
|
|
86
|
+
const c = mount(
|
|
87
|
+
h(HkStatCard, {
|
|
88
|
+
variant: "ring", tone: "success", pct: 37.5, value: "38%", unit: "cores",
|
|
89
|
+
icon: FakeIcon, label: "Cores", hint: "12 / 32 · monthly",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
93
|
+
expect(card.className).toContain("hk-stat-card-ring");
|
|
94
|
+
// exactly one ring block, painted with the tone palette
|
|
95
|
+
expect(c.querySelectorAll(".hk-gauge-ring").length).toBe(1);
|
|
96
|
+
expect(c.querySelector(".hk-gauge-ring-value")?.textContent).toBe("38%");
|
|
97
|
+
expect(c.querySelector(".hk-gauge-ring-label")?.textContent).toBe("cores");
|
|
98
|
+
expect(c.querySelector(".hk-stat-card-ring-label-text")?.textContent).toBe("Cores");
|
|
99
|
+
expect(c.querySelector(".hk-stat-card-ring-label svg[data-fake-icon]")).toBeTruthy();
|
|
100
|
+
expect(c.querySelector(".hk-stat-card-ring-detail")?.textContent).toBe("12 / 32 · monthly");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("clamps an over-range pct to a full gauge", () => {
|
|
104
|
+
const c = mount(h(HkStatCard, { variant: "ring", pct: 140, value: "140%", label: "L" }));
|
|
105
|
+
// The dash offset must reflect a clamped 100% fill, not an overshoot:
|
|
106
|
+
// a clamped ring draws dashoffset 0.
|
|
107
|
+
const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
|
|
108
|
+
expect(arc).toBeTruthy();
|
|
109
|
+
expect(arc.getAttribute("stroke-dashoffset")).toBe("0");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("clamps a negative pct to an empty gauge (the guard HkGaugeRing does not provide)", () => {
|
|
113
|
+
// HkGaugeRing only clamps the UPPER bound internally (Math.min(pct, 100)),
|
|
114
|
+
// so a negative pct surviving this component would draw dashoffset
|
|
115
|
+
// > circumference — an arc longer than the whole ring. This assertion is
|
|
116
|
+
// what kills a mutation that drops clampPct's lower clamp.
|
|
117
|
+
const c = mount(h(HkStatCard, { variant: "ring", pct: -20, value: "-20%", label: "L" }));
|
|
118
|
+
const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
|
|
119
|
+
expect(arc).toBeTruthy();
|
|
120
|
+
const circumference = 2 * Math.PI * ((104 - 7) / 2);
|
|
121
|
+
const offset = Number(arc.getAttribute("stroke-dashoffset"));
|
|
122
|
+
expect(Math.abs(offset - circumference)).toBeLessThan(0.01);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("treats a missing pct as an empty gauge, not NaN", () => {
|
|
126
|
+
const c = mount(h(HkStatCard, { variant: "ring", value: "—", label: "L" }));
|
|
127
|
+
const arc = c.querySelector(".hk-gauge-ring circle[stroke-dasharray]") as SVGElement;
|
|
128
|
+
expect(arc.getAttribute("stroke-dashoffset")).not.toContain("NaN");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("paints the ring through inline stroke styles, not only the presentation attribute", () => {
|
|
132
|
+
// var() in an SVG presentation attribute is not substituted on every
|
|
133
|
+
// engine (Firefox drops the declaration → stroke:none → an invisible
|
|
134
|
+
// gauge), so the tone color must ALSO ride the inline style — the same
|
|
135
|
+
// contract HkProgressRing documents for its segment color.
|
|
136
|
+
const c = mount(
|
|
137
|
+
h(HkStatCard, { variant: "ring", tone: "info", pct: 40, value: "40%", label: "L" }),
|
|
138
|
+
);
|
|
139
|
+
const [track, arc] = c.querySelectorAll(".hk-gauge-ring circle") as NodeListOf<SVGCircleElement>;
|
|
140
|
+
expect(track.getAttribute("stroke")).toBe("rgb(var(--color-text) / 8%)");
|
|
141
|
+
expect(track.style.stroke).toBe("rgb(var(--color-text) / 8%)");
|
|
142
|
+
expect(arc.getAttribute("stroke")).toBe(statToneColor("info"));
|
|
143
|
+
expect(arc.style.stroke).toBe(statToneColor("info"));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("HkStatCard clickable — role=button keyboard contract", () => {
|
|
148
|
+
it("activates on Enter and Space like a native button", async () => {
|
|
149
|
+
const clicks: string[] = [];
|
|
150
|
+
const c = mount(
|
|
151
|
+
h(HkStatCard, {
|
|
152
|
+
variant: "chip", value: 1, label: "L", clickable: true,
|
|
153
|
+
onClick: (e: MouseEvent | KeyboardEvent) => clicks.push("key" in e ? e.key : "mouse"),
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
157
|
+
for (const key of ["Enter", " "]) {
|
|
158
|
+
card.dispatchEvent(
|
|
159
|
+
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
|
160
|
+
);
|
|
161
|
+
await nextTick();
|
|
162
|
+
}
|
|
163
|
+
expect(clicks).toEqual(["Enter", " "]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("ignores keys bubbling from a focusable inside the card", async () => {
|
|
167
|
+
const clicks: unknown[] = [];
|
|
168
|
+
const c = mount(
|
|
169
|
+
h(HkStatCard, { variant: "chip", value: 1, label: "L", clickable: true, onClick: () => clicks.push(1) }, {
|
|
170
|
+
aside: () => h("button", { class: "aside-btn-probe" }, "x"),
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
const inner = c.querySelector(".aside-btn-probe") as HTMLElement;
|
|
174
|
+
inner.dispatchEvent(
|
|
175
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
176
|
+
);
|
|
177
|
+
await nextTick();
|
|
178
|
+
expect(clicks.length).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("HkStatCard bar", () => {
|
|
183
|
+
it("renders label+value over a tone-colored segmented fill", () => {
|
|
184
|
+
const c = mount(
|
|
185
|
+
h(HkStatCard, { variant: "bar", tone: "warning", pct: 72, label: "Storage", value: "72%", hint: "47 / 65 GB" }),
|
|
186
|
+
);
|
|
187
|
+
const card = c.querySelector(".hk-stat-card") as HTMLElement;
|
|
188
|
+
expect(card.className).toContain("hk-stat-card-bar");
|
|
189
|
+
expect(c.querySelector(".hk-stat-card-bar-head .hk-stat-card-label")?.textContent).toBe("Storage");
|
|
190
|
+
expect(c.querySelector(".hk-stat-card-bar-value")?.textContent).toBe("72%");
|
|
191
|
+
const seg = c.querySelector(".hk-progress-bar-seg") as HTMLElement;
|
|
192
|
+
expect(seg).toBeTruthy();
|
|
193
|
+
expect(seg.style.width).toBe("72%");
|
|
194
|
+
expect(seg.style.background).toBe("rgb(var(--color-warning))");
|
|
195
|
+
expect(c.querySelector(".hk-stat-card-bar-detail")?.textContent).toBe("47 / 65 GB");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("statToneColor", () => {
|
|
200
|
+
it("maps every tone to the theme-variable palette the card paints with", () => {
|
|
201
|
+
expect(statToneColor("success")).toBe("rgb(var(--color-success))");
|
|
202
|
+
expect(statToneColor("warning")).toBe("rgb(var(--color-warning))");
|
|
203
|
+
expect(statToneColor("error")).toBe("rgb(var(--color-error))");
|
|
204
|
+
expect(statToneColor("primary")).toBe("rgb(var(--color-primary))");
|
|
205
|
+
expect(statToneColor("muted")).toBe("rgb(var(--color-muted))");
|
|
206
|
+
// info falls back to primary when the host palette omits it — the
|
|
207
|
+
// same fallback the SCSS dot uses.
|
|
208
|
+
expect(statToneColor("info")).toBe("rgb(var(--color-info, rgb(var(--color-primary))))");
|
|
209
|
+
});
|
|
210
|
+
});
|