@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkFilterBar.scss +59 -0
  3. package/src/components/HkFilterBar.test.tsx +130 -0
  4. package/src/components/HkFilterBar.tsx +143 -0
  5. package/src/components/HkGaugeRing.tsx +13 -1
  6. package/src/components/HkSelectionGrid.scss +91 -0
  7. package/src/components/HkSelectionGrid.test.tsx +68 -0
  8. package/src/components/HkSelectionGrid.tsx +56 -1
  9. package/src/components/HkSettingsDialog.scss +12 -1
  10. package/src/components/HkStatCard.scss +107 -0
  11. package/src/components/HkStatCard.tsx +173 -21
  12. package/src/components/HkStatCard.variants.test.tsx +210 -0
  13. package/src/components/HkStepGuide.scss +177 -0
  14. package/src/components/HkStepGuide.test.tsx +81 -0
  15. package/src/components/HkStepGuide.tsx +129 -0
  16. package/src/components/HkThemeToggle.test.ts +23 -0
  17. package/src/components/HkThemeToggle.tsx +11 -2
  18. package/src/i18n/locales/ar/components.json +5 -1
  19. package/src/i18n/locales/de/components.json +5 -1
  20. package/src/i18n/locales/en/components.json +5 -1
  21. package/src/i18n/locales/es/components.json +5 -1
  22. package/src/i18n/locales/fr/components.json +5 -1
  23. package/src/i18n/locales/ja/components.json +5 -1
  24. package/src/i18n/locales/ko/components.json +5 -1
  25. package/src/i18n/locales/pt/components.json +5 -1
  26. package/src/i18n/locales/ru/components.json +5 -1
  27. package/src/i18n/locales/zh-Hans/components.json +5 -1
  28. package/src/i18n/locales/zh-Hant/components.json +5 -1
  29. package/src/index.ts +6 -1
  30. package/src/styles/admin-tokens.scss +21 -0
  31. package/src/theme/presets.ts +7 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.76",
3
+ "version": "0.55.78",
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",
@@ -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>
@@ -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
  });
@@ -3,6 +3,21 @@ import { defineComponent, type PropType } from "vue";
3
3
 
4
4
  import "./HkSelectionGrid.scss";
5
5
 
6
+ /**
7
+ * One trailing icon button on an item (e.g. a row's remove / dismiss
8
+ * affordance). Actions never select the item: their clicks stop the
9
+ * propagation the item's own handler rides on.
10
+ */
11
+ export interface SelectionGridAction {
12
+ id: string;
13
+ /** Lucide component rendered inside the button. */
14
+ icon?: ReturnType<typeof defineComponent>;
15
+ /** Accessible name — becomes the button's `title` (the house tooltip
16
+ * bridge upgrades it) and its aria-label. */
17
+ label: string;
18
+ disabled?: boolean;
19
+ }
20
+
6
21
  export interface SelectionGridItem {
7
22
  id: string;
8
23
  title: string;
@@ -10,6 +25,8 @@ export interface SelectionGridItem {
10
25
  badge?: string;
11
26
  badgeVariant?: string;
12
27
  icon?: ReturnType<typeof defineComponent>;
28
+ /** Trailing icon buttons (right edge), rendered before the check cell. */
29
+ actions?: SelectionGridAction[];
13
30
  }
14
31
 
15
32
  export type SelectionGridCols = 2 | 3 | 4;
@@ -37,9 +54,18 @@ export default defineComponent({
37
54
  groupTitle: { type: String, default: undefined },
38
55
  hint: { type: String, default: undefined },
39
56
  dense: { type: Boolean, default: false },
57
+ /** `list` lays the same options as full-width rows in one column (a
58
+ * picker list rather than a card grid): the check cell centers on the
59
+ * row's trailing edge instead of parking at the top corner. */
60
+ layout: { type: String as PropType<"grid" | "list">, default: "grid" },
61
+ /** Optional emphasis for the selected item: a thickened primary edge
62
+ * bar on the item's leading side (drawn inside the border box, so the
63
+ * geometry never shifts). */
64
+ selectedEdge: { type: Boolean, default: false },
40
65
  },
41
66
  emits: {
42
67
  select: (_item: SelectionGridItem) => true,
68
+ action: (_item: SelectionGridItem, _action: SelectionGridAction) => true,
43
69
  },
44
70
  setup(props, { emit }) {
45
71
  return () => {
@@ -54,7 +80,11 @@ export default defineComponent({
54
80
  props.minItemWidth > 0;
55
81
 
56
82
  return (
57
- <div class="hk-selection-grid">
83
+ <div
84
+ class="hk-selection-grid"
85
+ data-layout={props.layout}
86
+ data-edge={props.selectedEdge || undefined}
87
+ >
58
88
  {props.groupTitle && (
59
89
  <h4 class="hk-selection-grid-title">{props.groupTitle}</h4>
60
90
  )}
@@ -121,6 +151,31 @@ export default defineComponent({
121
151
  </p>
122
152
  )}
123
153
  </div>
154
+ {item.actions?.length ? (
155
+ <div class="hk-selection-grid-actions">
156
+ {item.actions.map((action) => {
157
+ const ActionIcon = action.icon;
158
+ return (
159
+ <button
160
+ key={action.id}
161
+ type="button"
162
+ class="hk-selection-grid-action"
163
+ title={action.label}
164
+ aria-label={action.label}
165
+ disabled={action.disabled || undefined}
166
+ onClick={(e: MouseEvent) => {
167
+ // An action is not a selection: without this
168
+ // the row's own handler fires too.
169
+ e.stopPropagation();
170
+ if (!action.disabled) emit("action", item, action);
171
+ }}
172
+ >
173
+ {ActionIcon && <ActionIcon size={14} />}
174
+ </button>
175
+ );
176
+ })}
177
+ </div>
178
+ ) : null}
124
179
  </div>
125
180
  );
126
181
  })}
@@ -54,7 +54,18 @@
54
54
 
55
55
  &:focus-visible {
56
56
  outline: 2px solid rgb(var(--color-primary));
57
- outline-offset: 1px;
57
+ // Drawn INSIDE the box: the rail's items sit flush with the rail's
58
+ // own start edge (the rail carries no start padding) and the rail
59
+ // scrolls, so an outside ring gets shaved down its inline-start
60
+ // side — "the selected item's contour is cut on the left" (user
61
+ // report 2026-09-23). Same reasoning as HSelectionGrid's inset ring.
62
+ //
63
+ // HOUSE CONVENTION (this bug class, third occurrence): a focus ring
64
+ // on a row that sits flush with a scroll container's edge MUST be
65
+ // drawn inside — `outline-offset: -2px`, or the border + inset-ring
66
+ // pairing HkSelectionGrid uses. Outside rings belong only on
67
+ // floating surfaces (popovers, panels) where nothing clips them.
68
+ outline-offset: -2px;
58
69
  }
59
70
 
60
71
  &:disabled {