@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.
- package/package.json +1 -1
- package/src/components/HkBoard.tsx +6 -0
- 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/HkMinimap.tsx +3 -0
- package/src/components/HkNodeCanvas.tsx +30 -21
- 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/HkSvgVarPresentation.test.tsx +170 -0
- package/src/i18n/locales/ar/components.json +4 -1
- package/src/i18n/locales/de/components.json +4 -1
- package/src/i18n/locales/en/components.json +4 -1
- package/src/i18n/locales/es/components.json +4 -1
- package/src/i18n/locales/fr/components.json +4 -1
- package/src/i18n/locales/ja/components.json +4 -1
- package/src/i18n/locales/ko/components.json +4 -1
- package/src/i18n/locales/pt/components.json +4 -1
- package/src/i18n/locales/ru/components.json +4 -1
- package/src/i18n/locales/zh-Hans/components.json +4 -1
- package/src/i18n/locales/zh-Hant/components.json +4 -1
- package/src/index.ts +6 -1
- package/src/styles/admin-tokens.scss +21 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
});
|