@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
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|