@celestia-island/hikari 0.55.77 → 0.55.79

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkBoard.tsx +6 -0
  3. package/src/components/HkFilterBar.scss +59 -0
  4. package/src/components/HkFilterBar.test.tsx +130 -0
  5. package/src/components/HkFilterBar.tsx +143 -0
  6. package/src/components/HkGaugeRing.tsx +13 -1
  7. package/src/components/HkMinimap.tsx +3 -0
  8. package/src/components/HkNodeCanvas.tsx +30 -21
  9. package/src/components/HkSelectionGrid.scss +91 -0
  10. package/src/components/HkSelectionGrid.test.tsx +68 -0
  11. package/src/components/HkSelectionGrid.tsx +56 -1
  12. package/src/components/HkSettingsDialog.scss +12 -1
  13. package/src/components/HkStatCard.scss +107 -0
  14. package/src/components/HkStatCard.tsx +173 -21
  15. package/src/components/HkStatCard.variants.test.tsx +210 -0
  16. package/src/components/HkStepGuide.scss +177 -0
  17. package/src/components/HkStepGuide.test.tsx +81 -0
  18. package/src/components/HkStepGuide.tsx +129 -0
  19. package/src/components/HkSvgVarPresentation.test.tsx +170 -0
  20. package/src/i18n/locales/ar/components.json +4 -1
  21. package/src/i18n/locales/de/components.json +4 -1
  22. package/src/i18n/locales/en/components.json +4 -1
  23. package/src/i18n/locales/es/components.json +4 -1
  24. package/src/i18n/locales/fr/components.json +4 -1
  25. package/src/i18n/locales/ja/components.json +4 -1
  26. package/src/i18n/locales/ko/components.json +4 -1
  27. package/src/i18n/locales/pt/components.json +4 -1
  28. package/src/i18n/locales/ru/components.json +4 -1
  29. package/src/i18n/locales/zh-Hans/components.json +4 -1
  30. package/src/i18n/locales/zh-Hant/components.json +4 -1
  31. package/src/index.ts +6 -1
  32. package/src/styles/admin-tokens.scss +21 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.77",
3
+ "version": "0.55.79",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -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,59 @@
1
+ /* HkFilterBar — the standard filter strip chrome (inline + popover). */
2
+
3
+ .hk-filter-bar {
4
+ min-width: 0;
5
+ }
6
+
7
+ /* ── inline: the standardized bare row ─────────────────────────────── */
8
+ .hk-filter-bar:not(.hk-filter-bar-popover) {
9
+ display: flex;
10
+ align-items: flex-end;
11
+ justify-content: space-between;
12
+ flex-wrap: wrap;
13
+ gap: var(--space-12, 0.75rem);
14
+ padding: var(--space-12, 0.75rem) var(--space-16, 1rem);
15
+ border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
16
+ border-radius: var(--radius-md, 0.5rem);
17
+ background: rgb(var(--color-surface));
18
+ }
19
+
20
+ .hk-filter-bar-fields {
21
+ display: flex;
22
+ align-items: flex-end;
23
+ flex-wrap: wrap;
24
+ gap: var(--space-12, 0.75rem);
25
+ min-width: 0;
26
+ }
27
+
28
+ .hk-filter-bar-actions {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: var(--space-8, 0.5rem);
32
+ flex-shrink: 0;
33
+ }
34
+
35
+ /* ── popover: collapsed behind the trigger button ──────────────────── */
36
+ .hk-filter-bar-popover {
37
+ display: inline-flex;
38
+ }
39
+
40
+ .hk-filter-bar-count {
41
+ font-variant-numeric: tabular-nums;
42
+ }
43
+
44
+ .hk-filter-bar-panel {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: var(--space-12, 0.75rem);
48
+ min-width: 280px;
49
+ padding: var(--space-4, 0.25rem);
50
+ }
51
+
52
+ .hk-filter-bar-footer {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: flex-end;
56
+ gap: var(--space-8, 0.5rem);
57
+ padding-top: var(--space-8, 0.5rem);
58
+ border-top: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
59
+ }
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkFilterBar from "./HkFilterBar";
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
+ function emitClick(el: Element) {
25
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
26
+ }
27
+
28
+ /** HkPopover teleports its surface to document.body — query the document,
29
+ * not the mount container, for everything inside the panel. */
30
+ const panelQuery = (sel: string) => document.body.querySelector(sel);
31
+
32
+ describe("HkFilterBar inline", () => {
33
+ it("renders the fields slot inside a search-landmark card row", () => {
34
+ const c = mount(
35
+ h(HkFilterBar, { label: "Ledger filters" }, { default: () => h("input", { "data-probe": "" }) }),
36
+ );
37
+ const bar = c.querySelector(".hk-filter-bar") as HTMLElement;
38
+ expect(bar).toBeTruthy();
39
+ expect(bar.getAttribute("role")).toBe("search");
40
+ expect(bar.getAttribute("aria-label")).toBe("Ledger filters");
41
+ expect(bar.className).not.toContain("hk-filter-bar-popover");
42
+ expect(c.querySelector(".hk-filter-bar-fields input[data-probe]")).toBeTruthy();
43
+ });
44
+
45
+ it("shows the reset button and the active-count badge", () => {
46
+ const c = mount(h(HkFilterBar, { activeCount: 2 }, { default: () => null }));
47
+ expect(c.querySelector(".hk-filter-bar-count")?.textContent).toBe("2");
48
+ const reset = c.querySelector(".hk-filter-bar-actions button") as HTMLButtonElement;
49
+ expect(reset).toBeTruthy();
50
+ expect(reset.textContent).toContain("Reset");
51
+ });
52
+
53
+ it("emits reset from the actions column and disables it at zero", () => {
54
+ const resets: number[] = [];
55
+ const c = mount(
56
+ h(HkFilterBar, { activeCount: 1, onReset: () => resets.push(1) }, { default: () => null }),
57
+ );
58
+ emitClick(c.querySelector(".hk-filter-bar-actions button")!);
59
+ expect(resets.length).toBe(1);
60
+ // zero active filters → the affordance is disabled, not hidden
61
+ const c2 = mount(h(HkFilterBar, { activeCount: 0 }, { default: () => null }));
62
+ const reset2 = c2.querySelector(".hk-filter-bar-actions button") as HTMLButtonElement;
63
+ expect(reset2.disabled).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("HkFilterBar popover", () => {
68
+ it("starts collapsed behind a trigger button carrying the count", () => {
69
+ const c = mount(
70
+ h(HkFilterBar, { mode: "popover", activeCount: 3 }, { default: () => h("input", { "data-probe": "" }) }),
71
+ );
72
+ const trigger = c.querySelector(".hk-filter-bar-popover button") as HTMLButtonElement;
73
+ expect(trigger).toBeTruthy();
74
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
75
+ expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
76
+ expect(trigger.textContent).toContain("Filters");
77
+ expect(trigger.querySelector(".hk-filter-bar-count")?.textContent).toBe("3");
78
+ // collapsed by default: the panel (and the fields with it) is not mounted
79
+ expect(panelQuery(".hk-filter-bar-panel")).toBeNull();
80
+ });
81
+
82
+ it("toggling the trigger opens the panel with the fields", async () => {
83
+ const c = mount(
84
+ h(HkFilterBar, { mode: "popover" }, { default: () => h("input", { "data-probe": "" }) }),
85
+ );
86
+ const trigger = c.querySelector(".hk-filter-bar-popover button") as HTMLButtonElement;
87
+ emitClick(trigger);
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
91
+ const panel = panelQuery(".hk-filter-bar-panel") as HTMLElement;
92
+ expect(panel).toBeTruthy();
93
+ expect(panel.querySelector("input[data-probe]")).toBeTruthy();
94
+ });
95
+
96
+ it("apply closes the popover and emits apply; footer carries reset", async () => {
97
+ const events: string[] = [];
98
+ const c = mount(
99
+ h(
100
+ HkFilterBar,
101
+ { mode: "popover", activeCount: 1, onApply: () => events.push("apply"), onReset: () => events.push("reset") },
102
+ { default: () => h("input", { "data-probe": "" }) },
103
+ ),
104
+ );
105
+ emitClick(c.querySelector(".hk-filter-bar-popover button")!);
106
+ await Promise.resolve();
107
+ await Promise.resolve();
108
+ const buttons = Array.from(document.body.querySelectorAll(".hk-filter-bar-footer button")) as HTMLButtonElement[];
109
+ expect(buttons.length).toBe(2);
110
+ expect(buttons[0].textContent).toContain("Reset");
111
+ expect(buttons[1].textContent).toContain("Apply");
112
+ emitClick(buttons[1]);
113
+ await Promise.resolve();
114
+ await Promise.resolve();
115
+ expect(events).toEqual(["apply"]);
116
+ expect(
117
+ (c.querySelector(".hk-filter-bar-popover button") as HTMLButtonElement).getAttribute("aria-expanded"),
118
+ ).toBe("false");
119
+ });
120
+
121
+ it("showFooter=false drops the footer (live-commit panels)", async () => {
122
+ const c = mount(
123
+ h(HkFilterBar, { mode: "popover", showFooter: false }, { default: () => null }),
124
+ );
125
+ emitClick(c.querySelector(".hk-filter-bar-popover button")!);
126
+ await Promise.resolve();
127
+ await Promise.resolve();
128
+ expect(panelQuery(".hk-filter-bar-footer")).toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,143 @@
1
+ import { defineComponent, ref, type PropType } from "vue";
2
+ import { SlidersHorizontal } from "lucide-vue-next";
3
+
4
+ import HkBadge from "./HkBadge";
5
+ import HkButton from "./HkButton";
6
+ import HkPopover, { type PopupPlacement } from "./HkPopover";
7
+ import { useI18n } from "../i18n/context";
8
+ import "./HkFilterBar.scss";
9
+
10
+ /**
11
+ * HkFilterBar — the standard filter strip for list/table pages.
12
+ *
13
+ * Two standardized shapes (a page picks one; hand-rolled rows of inputs
14
+ * are retired in favor of this):
15
+ *
16
+ * - `inline` (default) — the bare row, standardized: one card surface,
17
+ * aligned fields, and a trailing actions column carrying the active-
18
+ * filter count and the reset button. For pages where the filters ARE
19
+ * the primary interaction (dense ledgers).
20
+ * - `popover` — the collapsed shape: one trigger button (icon + label +
21
+ * active-count badge) anchoring a popover panel that holds the same
22
+ * fields plus an Apply/Reset footer. The default posture for filters —
23
+ * they should not permanently occupy a band of the page.
24
+ *
25
+ * Fields are the consumer's own controls in the default slot; the bar
26
+ * owns only the chrome, the count and the apply/reset verbs.
27
+ *
28
+ * ```tsx
29
+ * <HkFilterBar mode="popover" activeCount={n} onApply={search} onReset={clearFilters}>
30
+ * <HkSelect … /> <HkInput … />
31
+ * </HkFilterBar>
32
+ * ```
33
+ */
34
+ export default defineComponent({
35
+ name: "HkFilterBar",
36
+ props: {
37
+ mode: { type: String as PropType<"inline" | "popover">, default: "inline" },
38
+ /** How many filters are currently active (drives the count badge). */
39
+ activeCount: { type: Number, default: 0 },
40
+ /** Accessible name / trigger text. Defaults to the localized "Filters". */
41
+ label: { type: String, default: undefined },
42
+ /** popover mode: render the Apply/Reset footer. Keep it off only when
43
+ * the fields commit live on their own. */
44
+ showFooter: { type: Boolean, default: true },
45
+ disabled: { type: Boolean, default: false },
46
+ placement: { type: String as PropType<PopupPlacement>, default: "bottom-start" },
47
+ },
48
+ emits: {
49
+ /** popover mode: the Apply button in the footer. */
50
+ apply: () => true,
51
+ /** The reset button (inline actions column / popover footer). */
52
+ reset: () => true,
53
+ },
54
+ setup(props, { emit, slots }) {
55
+ const { t } = useI18n();
56
+ const open = ref(false);
57
+ const anchorRef = ref<HTMLElement | null>(null);
58
+
59
+ const resetButton = () => (
60
+ <HkButton
61
+ variant="ghost"
62
+ size="sm"
63
+ disabled={props.disabled || props.activeCount === 0}
64
+ onClick={() => emit("reset")}
65
+ >
66
+ {t("hikari::filterBar.reset", "Reset")}
67
+ </HkButton>
68
+ );
69
+
70
+ return () => {
71
+ const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
72
+
73
+ if (props.mode === "popover") {
74
+ return (
75
+ <div
76
+ ref={anchorRef}
77
+ class="hk-filter-bar hk-filter-bar-popover"
78
+ >
79
+ <HkButton
80
+ variant="secondary"
81
+ size="sm"
82
+ disabled={props.disabled}
83
+ ariaLabel={label}
84
+ aria-expanded={open.value ? "true" : "false"}
85
+ aria-haspopup="dialog"
86
+ onClick={() => (open.value = !open.value)}
87
+ >
88
+ <SlidersHorizontal size={14} aria-hidden="true" />
89
+ <span>{label}</span>
90
+ {props.activeCount > 0 && (
91
+ <HkBadge size="sm" variant="primary" class="hk-filter-bar-count">
92
+ {props.activeCount}
93
+ </HkBadge>
94
+ )}
95
+ </HkButton>
96
+ <HkPopover
97
+ modelValue={open.value}
98
+ onUpdate:modelValue={(v: boolean) => (open.value = v)}
99
+ anchorRef={anchorRef.value}
100
+ placement={props.placement}
101
+ offset={6}
102
+ title={label}
103
+ >
104
+ <div class="hk-filter-bar-panel" data-testid="hk-filter-bar-panel">
105
+ {slots.default?.()}
106
+ {props.showFooter && (
107
+ <div class="hk-filter-bar-footer">
108
+ {resetButton()}
109
+ <HkButton
110
+ variant="primary"
111
+ size="sm"
112
+ disabled={props.disabled}
113
+ onClick={() => {
114
+ open.value = false;
115
+ emit("apply");
116
+ }}
117
+ >
118
+ {t("hikari::filterBar.apply", "Apply")}
119
+ </HkButton>
120
+ </div>
121
+ )}
122
+ </div>
123
+ </HkPopover>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ return (
129
+ <div class="hk-filter-bar" role="search" aria-label={label}>
130
+ <div class="hk-filter-bar-fields">{slots.default?.()}</div>
131
+ <div class="hk-filter-bar-actions">
132
+ {props.activeCount > 0 && (
133
+ <HkBadge size="sm" variant="primary" class="hk-filter-bar-count">
134
+ {props.activeCount}
135
+ </HkBadge>
136
+ )}
137
+ {resetButton()}
138
+ </div>
139
+ </div>
140
+ );
141
+ };
142
+ },
143
+ });
@@ -10,6 +10,14 @@ interface RingData {
10
10
  trackColor: string;
11
11
  }
12
12
 
13
+ // Ring colors ride INLINE STYLES, not just the stroke presentation
14
+ // attributes: var() references in a presentation attribute are not
15
+ // substituted on every engine (Firefox drops the whole declaration →
16
+ // stroke:none → an invisible ring; svgwg#1031, 2025-11: browsers still
17
+ // disagree and the spec has not pinned the behavior). Same contract
18
+ // HkProgressRing documents for its segment color.
19
+
20
+
13
21
  const RING_ANIM_MS = 800;
14
22
 
15
23
  export default defineComponent({
@@ -95,6 +103,7 @@ export default defineComponent({
95
103
  r={ring.radius}
96
104
  fill="none"
97
105
  stroke={ring.trackColor}
106
+ style={{ stroke: ring.trackColor }}
98
107
  stroke-width={ring.strokeWidth}
99
108
  />
100
109
  <circle
@@ -108,7 +117,10 @@ export default defineComponent({
108
117
  stroke-dasharray={ring.circumference}
109
118
  stroke-dashoffset={ring.dashOffset}
110
119
  style={{
111
- transition: props.animate ? "stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1)" : "none",
120
+ stroke: ring.color,
121
+ transition: props.animate
122
+ ? "stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1)"
123
+ : "none",
112
124
  }}
113
125
  />
114
126
  </g>
@@ -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"
@@ -1293,27 +1293,35 @@ export default defineComponent({
1293
1293
  data-edge-id={edge.id}
1294
1294
  data-edge-type={edge.type}
1295
1295
  >
1296
- {props.interactiveEdges && strokes.map((sp, i) => (
1297
- <path
1298
- key={`halo-${i}`}
1299
- class="hk-node-canvas-edge-halo"
1300
- d={sp.d}
1301
- fill="none"
1302
- stroke={edge.color ?? "var(--hk-border-3, #555)"}
1303
- stroke-width={sp.width * 3}
1304
- stroke-dasharray={sp.dashed ? "6 4" : undefined}
1305
- />
1306
- ))}
1307
- {strokes.map((sp, i) => (
1308
- <path
1309
- key={`ink-${i}`}
1310
- d={sp.d}
1311
- fill="none"
1312
- stroke={edge.color ?? "var(--hk-border-3, #555)"}
1313
- stroke-width={sp.width}
1314
- stroke-dasharray={sp.dashed ? "6 4" : undefined}
1315
- />
1316
- ))}
1296
+ {props.interactiveEdges && strokes.map((sp, i) => {
1297
+ const edgeInk = edge.color ?? "var(--hk-border-3, #555)";
1298
+ return (
1299
+ <path
1300
+ key={`halo-${i}`}
1301
+ class="hk-node-canvas-edge-halo"
1302
+ d={sp.d}
1303
+ fill="none"
1304
+ stroke={edgeInk}
1305
+ style={{ stroke: edgeInk }}
1306
+ stroke-width={sp.width * 3}
1307
+ stroke-dasharray={sp.dashed ? "6 4" : undefined}
1308
+ />
1309
+ );
1310
+ })}
1311
+ {strokes.map((sp, i) => {
1312
+ const edgeInk = edge.color ?? "var(--hk-border-3, #555)";
1313
+ return (
1314
+ <path
1315
+ key={`ink-${i}`}
1316
+ d={sp.d}
1317
+ fill="none"
1318
+ stroke={edgeInk}
1319
+ style={{ stroke: edgeInk }}
1320
+ stroke-width={sp.width}
1321
+ stroke-dasharray={sp.dashed ? "6 4" : undefined}
1322
+ />
1323
+ );
1324
+ })}
1317
1325
  {edge.label && (
1318
1326
  <text
1319
1327
  x={mid.x}
@@ -1321,6 +1329,7 @@ export default defineComponent({
1321
1329
  text-anchor="middle"
1322
1330
  font-size="11"
1323
1331
  fill="var(--hk-text-2, #aaa)"
1332
+ style={{ fill: "var(--hk-text-2, #aaa)" }}
1324
1333
  >
1325
1334
  {edge.label}
1326
1335
  </text>
@@ -218,3 +218,94 @@
218
218
  transition: opacity 200ms ease;
219
219
  }
220
220
  }
221
+
222
+ /* ── List layout ─────────────────────────────────────────────────────────
223
+ The same option vocabulary as full-width rows in ONE column — the
224
+ picker-list dress (a credential, an image, a disk) rather than a card
225
+ grid. The check leaves the corner and sits centered on the row's
226
+ trailing edge, where a list's selection mark belongs. */
227
+ .hk-selection-grid[data-layout="list"] {
228
+ .hk-selection-grid-grid {
229
+ grid-template-columns: minmax(0, 1fr);
230
+ }
231
+
232
+ .hk-selection-grid-item {
233
+ align-items: center;
234
+ padding-inline-end: var(--space-40, 2.5rem);
235
+ }
236
+
237
+ .hk-selection-grid-check {
238
+ top: 50%;
239
+ translate: 0 -50%;
240
+ inset-inline-end: var(--space-10, 0.625rem);
241
+ }
242
+ }
243
+
244
+ /* ── Trailing item actions ───────────────────────────────────────────────
245
+ Icon buttons on the row's right edge (remove / dismiss …). They never
246
+ select the item — the component stops their click propagation — and
247
+ they sit in the row's own trailing cell. */
248
+ .hk-selection-grid-actions {
249
+ flex: none;
250
+ display: flex;
251
+ align-items: center;
252
+ gap: var(--space-2, 0.125rem);
253
+ margin-inline-start: auto;
254
+ align-self: center;
255
+ // Keep clear of the corner check in grid layout; the list layout already
256
+ // reserves that room on the item itself.
257
+ padding-inline-end: var(--space-16, 1rem);
258
+ }
259
+
260
+ .hk-selection-grid[data-layout="list"] .hk-selection-grid-actions {
261
+ padding-inline-end: 0;
262
+ }
263
+
264
+ .hk-selection-grid-action {
265
+ flex: none;
266
+ display: inline-flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ width: 1.5rem;
270
+ height: 1.5rem;
271
+ padding: 0;
272
+ border: 0;
273
+ border-radius: var(--radius-sm, 4px);
274
+ background: transparent;
275
+ color: var(--hi-color-text-tertiary, #8b949e);
276
+ cursor: pointer;
277
+ transition:
278
+ color 200ms ease,
279
+ background 200ms ease;
280
+
281
+ &:hover:not(:disabled) {
282
+ color: rgb(var(--color-text));
283
+ background: rgb(var(--color-text) / 8%);
284
+ }
285
+
286
+ &:focus-visible {
287
+ outline: 2px solid rgb(var(--color-primary));
288
+ outline-offset: -2px;
289
+ }
290
+
291
+ &:disabled {
292
+ opacity: 0.45;
293
+ cursor: not-allowed;
294
+ }
295
+ }
296
+
297
+ /* ── Optional selected edge ──────────────────────────────────────────────
298
+ A thickened primary bar on the item's LEADING side, drawn inside the
299
+ border box (a pseudo element, so nothing shifts) — the "this is the
300
+ chosen one" cue some hosts want on top of the ring + check. */
301
+ .hk-selection-grid[data-edge] .hk-selection-grid-item[data-selected]::before {
302
+ content: "";
303
+ position: absolute;
304
+ inset-inline-start: 0;
305
+ top: 0;
306
+ bottom: 0;
307
+ width: 3px;
308
+ border-start-start-radius: var(--radius-sm, 4px);
309
+ border-end-start-radius: var(--radius-sm, 4px);
310
+ background: var(--hi-color-primary, #7aa2f7);
311
+ }
@@ -165,4 +165,72 @@ describe("HkSelectionGrid adaptive floor mode (minItemWidth)", () => {
165
165
  expect(grid.getAttribute("data-cols")).toBe("3");
166
166
  }
167
167
  });
168
+
169
+ describe("HkSelectionGrid list layout, actions and edge", () => {
170
+ it("marks the root with the layout and edge hooks", () => {
171
+ const container = mount(
172
+ h(HkSelectionGrid, { items: ITEMS, layout: "list", selectedEdge: true }),
173
+ );
174
+ const root = container.querySelector(".hk-selection-grid")!;
175
+ expect(root.getAttribute("data-layout")).toBe("list");
176
+ expect(root.hasAttribute("data-edge")).toBe(true);
177
+ });
178
+
179
+ it("defaults to the grid layout with no edge", () => {
180
+ const container = mount(h(HkSelectionGrid, { items: ITEMS }));
181
+ const root = container.querySelector(".hk-selection-grid")!;
182
+ expect(root.getAttribute("data-layout")).toBe("grid");
183
+ expect(root.hasAttribute("data-edge")).toBe(false);
184
+ });
185
+
186
+ it("renders per-item trailing actions as labelled icon buttons", () => {
187
+ const items: SelectionGridItem[] = [
188
+ { id: "a", title: "A", actions: [{ id: "rm", label: "Remove" }] },
189
+ { id: "b", title: "B" },
190
+ ];
191
+ const container = mount(h(HkSelectionGrid, { items }));
192
+ const buttons = container.querySelectorAll(".hk-selection-grid-action");
193
+ expect(buttons.length).toBe(1);
194
+ expect(buttons[0].getAttribute("title")).toBe("Remove");
195
+ expect(buttons[0].getAttribute("aria-label")).toBe("Remove");
196
+ // Only the item that declared actions renders the actions cell.
197
+ const rows = cards(container);
198
+ expect(rows[0].querySelector(".hk-selection-grid-actions")).not.toBeNull();
199
+ expect(rows[1].querySelector(".hk-selection-grid-actions")).toBeNull();
200
+ });
201
+
202
+ it("emits action without selecting the item (propagation stopped)", () => {
203
+ const seen: Array<[string, string]> = [];
204
+ const items: SelectionGridItem[] = [
205
+ { id: "a", title: "A", actions: [{ id: "rm", label: "Remove" }] },
206
+ ];
207
+ const container = mount(
208
+ h(HkSelectionGrid, {
209
+ items,
210
+ onSelect: (item: SelectionGridItem) => seen.push(["select", item.id]),
211
+ onAction: (item: SelectionGridItem, action: { id: string }) =>
212
+ seen.push(["action", `${item.id}:${action.id}`]),
213
+ }),
214
+ );
215
+ (container.querySelector(".hk-selection-grid-action") as HTMLElement).click();
216
+ expect(seen).toEqual([["action", "a:rm"]]);
217
+ });
218
+
219
+ it("suppresses the action emit when the action is disabled", () => {
220
+ const seen: string[] = [];
221
+ const items: SelectionGridItem[] = [
222
+ { id: "a", title: "A", actions: [{ id: "rm", label: "Remove", disabled: true }] },
223
+ ];
224
+ const container = mount(
225
+ h(HkSelectionGrid, {
226
+ items,
227
+ onAction: () => seen.push("action"),
228
+ }),
229
+ );
230
+ const btn = container.querySelector(".hk-selection-grid-action") as HTMLButtonElement;
231
+ expect(btn.disabled).toBe(true);
232
+ btn.click();
233
+ expect(seen).toEqual([]);
234
+ });
235
+ });
168
236
  });