@kahitsan/ksui 0.34.1 → 0.36.0
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/base/AccountAvatar.tsx +1 -0
- package/src/components/base/DataTable.tsx +4 -4
- package/src/components/base/SegmentedFilter.test.tsx +111 -0
- package/src/components/base/SegmentedFilter.tsx +52 -17
- package/src/components/composite/FlowGraph.test.tsx +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -64,6 +64,7 @@ export function buildInitialsSvg(name: string): string {
|
|
|
64
64
|
`<circle cx="50" cy="50" r="50" fill="${color}"/>`,
|
|
65
65
|
`<text x="50" y="50" text-anchor="middle" dominant-baseline="central" ` +
|
|
66
66
|
`fill="white" font-size="${fontSize}" font-weight="bold" ` +
|
|
67
|
+
// rendered via <img src="data:...">, an isolated document — var(--ks-font-*) cannot resolve here, so left literal
|
|
67
68
|
`font-family="system-ui,-apple-system,sans-serif">${initials}</text>`,
|
|
68
69
|
"</svg>",
|
|
69
70
|
].join("");
|
|
@@ -118,7 +118,7 @@ const DATATABLE_CSS = `
|
|
|
118
118
|
@media (min-width:640px){.ksui-datatable-search-input{width:18rem;}}
|
|
119
119
|
.ksui-datatable-scroll{overflow-x:auto;transition:opacity 0.15s ease;}
|
|
120
120
|
.ksui-datatable-table{width:100%;text-align:left;font-size:0.78125rem;line-height:1.3;border-collapse:collapse;}
|
|
121
|
-
.ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,var(--ks-border,rgba(39,39,42,0.5)));background:var(--ksui-dt-thead-bg,color-mix(in srgb, var(--ks-fg, #ffffff) 3%, transparent));font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.65625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-dt-muted,var(--ks-fg-subtle,#71717a));}
|
|
121
|
+
.ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,var(--ks-border,rgba(39,39,42,0.5)));background:var(--ksui-dt-thead-bg,color-mix(in srgb, var(--ks-fg, #ffffff) 3%, transparent));font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-size:0.65625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-dt-muted,var(--ks-fg-subtle,#71717a));}
|
|
122
122
|
.ksui-datatable-th{padding:0.5625rem 0.875rem;background:var(--ksui-dt-thead-bg,color-mix(in srgb, var(--ks-fg, #ffffff) 3%, transparent));white-space:nowrap;}
|
|
123
123
|
.ksui-datatable-th-num{text-align:right;}
|
|
124
124
|
.ksui-datatable-th-sortable{cursor:pointer;transition:color 0.15s ease;}
|
|
@@ -130,9 +130,9 @@ const DATATABLE_CSS = `
|
|
|
130
130
|
.ksui-datatable-row-clickable{cursor:pointer;}
|
|
131
131
|
.ksui-datatable-row-clickable:active{background-color:var(--ksui-dt-row-active,color-mix(in srgb, var(--ks-border-strong, #3f3f46) 70%, transparent));}
|
|
132
132
|
.ksui-datatable-td{padding:0.5rem 0.875rem;vertical-align:middle;white-space:nowrap;}
|
|
133
|
-
.ksui-datatable-td-num{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;color:var(--ksui-dt-text-strong,var(--ks-fg,#ffffff));}
|
|
134
|
-
.ksui-datatable-code{display:block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.71875rem;color:var(--ksui-dt-fg,var(--ks-fg,#ffffff));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34ch;}
|
|
135
|
-
.ksui-datatable-badge{display:inline-block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.625rem;padding:0.125rem 0.4375rem;border-radius:0.3125rem;letter-spacing:0.02em;background:var(--ksui-dt-control-bg,var(--ks-input-bg,#18181b));color:var(--ksui-dt-text,var(--ks-fg-muted,#a1a1aa));}
|
|
133
|
+
.ksui-datatable-td-num{font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;color:var(--ksui-dt-text-strong,var(--ks-fg,#ffffff));}
|
|
134
|
+
.ksui-datatable-code{display:block;font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-size:0.71875rem;color:var(--ksui-dt-fg,var(--ks-fg,#ffffff));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34ch;}
|
|
135
|
+
.ksui-datatable-badge{display:inline-block;font-family:var(--ks-font-mono,ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace);font-size:0.625rem;padding:0.125rem 0.4375rem;border-radius:0.3125rem;letter-spacing:0.02em;background:var(--ksui-dt-control-bg,var(--ks-input-bg,#18181b));color:var(--ksui-dt-text,var(--ks-fg-muted,#a1a1aa));}
|
|
136
136
|
.ksui-datatable-badge-ok{background:var(--ksui-dt-good-bg,color-mix(in srgb, var(--ks-success-fg, #34d399) 14%, transparent));color:var(--ksui-dt-good,var(--ks-success-fg,#34d399));}
|
|
137
137
|
.ksui-datatable-badge-warn{background:var(--ksui-dt-accent-bg,color-mix(in srgb, var(--ks-accent, #fbbf24) 20%, transparent));color:var(--ksui-dt-accent,var(--ks-accent,#fbbf24));}
|
|
138
138
|
.ksui-datatable-badge-danger{background:var(--ksui-dt-danger-bg,color-mix(in srgb, var(--ks-danger-fg, #f87171) 14%, transparent));color:var(--ksui-dt-danger,var(--ks-danger-fg,#f87171));}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, fireEvent } from "@solidjs/testing-library";
|
|
3
|
+
import SegmentedFilter, { type SegmentedFilterOption } from "./SegmentedFilter";
|
|
4
|
+
|
|
5
|
+
// Back-compat: bare-string options are still accepted alongside the object
|
|
6
|
+
// form, so callers frozen on the old shape don't need a migration.
|
|
7
|
+
const STRING_OPTIONS: SegmentedFilterOption[] = ["today", "week", "month"];
|
|
8
|
+
|
|
9
|
+
const OBJECT_OPTIONS: SegmentedFilterOption[] = [
|
|
10
|
+
{ value: "table", label: "Table" },
|
|
11
|
+
{ value: "calendar", label: "Calendar" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const WITH_DISABLED: SegmentedFilterOption[] = [
|
|
15
|
+
{ value: "daily", label: "Per day" },
|
|
16
|
+
{ value: "hourly", label: "Per hour", disabled: true, disabledNote: "Unavailable for this staff type" },
|
|
17
|
+
{ value: "fixed", label: "Fixed period" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("SegmentedFilter", () => {
|
|
21
|
+
it("renders bare-string options capitalized (back-compat)", () => {
|
|
22
|
+
const { getAllByRole } = render(() => (
|
|
23
|
+
<SegmentedFilter options={STRING_OPTIONS} value="today" onChange={() => {}} />
|
|
24
|
+
));
|
|
25
|
+
const radios = getAllByRole("radio");
|
|
26
|
+
expect(radios.map((r) => r.textContent)).toEqual(["today", "week", "month"]);
|
|
27
|
+
expect(radios[0].getAttribute("aria-checked")).toBe("true");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders object options with an explicit label", () => {
|
|
31
|
+
const { getAllByRole } = render(() => (
|
|
32
|
+
<SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={() => {}} />
|
|
33
|
+
));
|
|
34
|
+
const radios = getAllByRole("radio");
|
|
35
|
+
expect(radios.map((r) => r.textContent)).toEqual(["Table", "Calendar"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("emits the clicked value on an enabled segment", () => {
|
|
39
|
+
const onChange = vi.fn();
|
|
40
|
+
const { getAllByRole } = render(() => (
|
|
41
|
+
<SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={onChange} />
|
|
42
|
+
));
|
|
43
|
+
fireEvent.click(getAllByRole("radio")[1]);
|
|
44
|
+
expect(onChange).toHaveBeenCalledWith("calendar");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("no-ops a click on a disabled segment", () => {
|
|
48
|
+
const onChange = vi.fn();
|
|
49
|
+
const { getAllByRole } = render(() => (
|
|
50
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
|
|
51
|
+
));
|
|
52
|
+
fireEvent.click(getAllByRole("radio")[1]);
|
|
53
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("exposes aria-disabled and the disabledNote via title + sr-only text", () => {
|
|
57
|
+
const { getAllByRole, getByText } = render(() => (
|
|
58
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
|
|
59
|
+
));
|
|
60
|
+
const disabledRadio = getAllByRole("radio")[1];
|
|
61
|
+
expect(disabledRadio.getAttribute("aria-disabled")).toBe("true");
|
|
62
|
+
expect(disabledRadio.getAttribute("title")).toBe("Unavailable for this staff type");
|
|
63
|
+
expect(getByText("Unavailable for this staff type").className).toContain("sr-only");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does not set aria-disabled on enabled segments", () => {
|
|
67
|
+
const { getAllByRole } = render(() => (
|
|
68
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
|
|
69
|
+
));
|
|
70
|
+
expect(getAllByRole("radio")[0].getAttribute("aria-disabled")).toBe("false");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("pulls disabled segments out of tab order", () => {
|
|
74
|
+
const { getAllByRole } = render(() => (
|
|
75
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
|
|
76
|
+
));
|
|
77
|
+
expect(getAllByRole("radio")[1].getAttribute("tabindex")).toBe("-1");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("ArrowRight skips a disabled segment and selects the next enabled one", () => {
|
|
81
|
+
const onChange = vi.fn();
|
|
82
|
+
const { container } = render(() => (
|
|
83
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
|
|
84
|
+
));
|
|
85
|
+
fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "ArrowRight" });
|
|
86
|
+
expect(onChange).toHaveBeenCalledWith("fixed");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("ArrowLeft wraps past a disabled segment to the previous enabled one", () => {
|
|
90
|
+
const onChange = vi.fn();
|
|
91
|
+
const { container } = render(() => (
|
|
92
|
+
<SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
|
|
93
|
+
));
|
|
94
|
+
fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "ArrowLeft" });
|
|
95
|
+
expect(onChange).toHaveBeenCalledWith("fixed");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("End lands on the last enabled segment even if the last option is disabled", () => {
|
|
99
|
+
const onChange = vi.fn();
|
|
100
|
+
const trailingDisabled: SegmentedFilterOption[] = [
|
|
101
|
+
{ value: "a", label: "A" },
|
|
102
|
+
{ value: "b", label: "B" },
|
|
103
|
+
{ value: "c", label: "C", disabled: true },
|
|
104
|
+
];
|
|
105
|
+
const { container } = render(() => (
|
|
106
|
+
<SegmentedFilter options={trailingDisabled} value="a" onChange={onChange} />
|
|
107
|
+
));
|
|
108
|
+
fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "End" });
|
|
109
|
+
expect(onChange).toHaveBeenCalledWith("b");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -2,8 +2,13 @@ import { For, type JSX } from "solid-js";
|
|
|
2
2
|
|
|
3
3
|
/** One choice in the segmented row. A bare string uses the value as the label
|
|
4
4
|
* and is rendered capitalized; an object lets the caller supply an explicit
|
|
5
|
-
* label that is NOT capitalized (for buckets or other non-status toggles).
|
|
6
|
-
|
|
5
|
+
* label that is NOT capitalized (for buckets or other non-status toggles).
|
|
6
|
+
* `disabled` mutes the segment and pulls it out of roving arrow-key nav;
|
|
7
|
+
* `disabledNote` (shown only when disabled) explains why via title + sr-only
|
|
8
|
+
* text, since a muted, unclickable control is otherwise unexplained. */
|
|
9
|
+
export type SegmentedFilterOption =
|
|
10
|
+
| string
|
|
11
|
+
| { value: string; label: string; disabled?: boolean; disabledNote?: string };
|
|
7
12
|
|
|
8
13
|
interface SegmentedFilterProps {
|
|
9
14
|
/** The available segments, left to right. */
|
|
@@ -29,19 +34,28 @@ interface SegmentedFilterProps {
|
|
|
29
34
|
export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Element {
|
|
30
35
|
const buttonRefs: (HTMLButtonElement | undefined)[] = [];
|
|
31
36
|
const optionOf = (o: SegmentedFilterOption) =>
|
|
32
|
-
typeof o === "string"
|
|
37
|
+
typeof o === "string"
|
|
38
|
+
? { value: o, label: o, capitalize: true, disabled: false, disabledNote: undefined as string | undefined }
|
|
39
|
+
: { disabled: false, disabledNote: undefined as string | undefined, ...o, capitalize: false };
|
|
33
40
|
|
|
34
41
|
const currentIndex = () => {
|
|
35
42
|
const i = props.options.findIndex((o) => optionOf(o).value === props.value);
|
|
36
43
|
return i >= 0 ? i : 0;
|
|
37
44
|
};
|
|
38
45
|
|
|
39
|
-
|
|
46
|
+
// Roving nav must skip disabled segments while preserving the requested
|
|
47
|
+
// direction (Home/End pass direction 1 since idx is already the boundary);
|
|
48
|
+
// a full lap finding no enabled option is the only way this doesn't move.
|
|
49
|
+
const selectByIndex = (idx: number, direction: 1 | -1 = 1) => {
|
|
40
50
|
const list = props.options;
|
|
41
51
|
if (list.length === 0) return;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
for (let step = 0, cursor = idx; step < list.length; step++, cursor += direction) {
|
|
53
|
+
const wrapped = ((cursor % list.length) + list.length) % list.length;
|
|
54
|
+
if (optionOf(list[wrapped]).disabled) continue;
|
|
55
|
+
props.onChange(optionOf(list[wrapped]).value);
|
|
56
|
+
buttonRefs[wrapped]?.focus();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -49,20 +63,20 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
|
|
|
49
63
|
case "ArrowRight":
|
|
50
64
|
case "ArrowDown":
|
|
51
65
|
e.preventDefault();
|
|
52
|
-
selectByIndex(currentIndex() + 1);
|
|
66
|
+
selectByIndex(currentIndex() + 1, 1);
|
|
53
67
|
break;
|
|
54
68
|
case "ArrowLeft":
|
|
55
69
|
case "ArrowUp":
|
|
56
70
|
e.preventDefault();
|
|
57
|
-
selectByIndex(currentIndex() - 1);
|
|
71
|
+
selectByIndex(currentIndex() - 1, -1);
|
|
58
72
|
break;
|
|
59
73
|
case "Home":
|
|
60
74
|
e.preventDefault();
|
|
61
|
-
selectByIndex(0);
|
|
75
|
+
selectByIndex(0, 1);
|
|
62
76
|
break;
|
|
63
77
|
case "End":
|
|
64
78
|
e.preventDefault();
|
|
65
|
-
selectByIndex(props.options.length - 1);
|
|
79
|
+
selectByIndex(props.options.length - 1, -1);
|
|
66
80
|
break;
|
|
67
81
|
}
|
|
68
82
|
};
|
|
@@ -78,25 +92,46 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
|
|
|
78
92
|
{(o, i) => {
|
|
79
93
|
const opt = optionOf(o);
|
|
80
94
|
const selected = () => props.value === opt.value;
|
|
81
|
-
|
|
95
|
+
// Default tab stop falls to the first enabled option when nothing
|
|
96
|
+
// is selected yet, so an all-disabled-first row is still reachable.
|
|
97
|
+
const firstEnabledIndex = () => {
|
|
98
|
+
const idx = props.options.findIndex((candidate) => !optionOf(candidate).disabled);
|
|
99
|
+
return idx >= 0 ? idx : 0;
|
|
100
|
+
};
|
|
101
|
+
const isTabStop = () => selected() || (!props.value && i() === firstEnabledIndex());
|
|
102
|
+
const noteId = `${props.testIdPrefix ?? "segmented"}-${opt.value}-note`;
|
|
82
103
|
return (
|
|
83
104
|
<button
|
|
84
105
|
ref={(el) => (buttonRefs[i()] = el)}
|
|
85
106
|
type="button"
|
|
86
107
|
role="radio"
|
|
87
108
|
aria-checked={selected()}
|
|
88
|
-
|
|
109
|
+
aria-disabled={opt.disabled}
|
|
110
|
+
aria-describedby={opt.disabled && opt.disabledNote ? noteId : undefined}
|
|
111
|
+
title={opt.disabled ? opt.disabledNote : undefined}
|
|
112
|
+
tabIndex={opt.disabled ? -1 : isTabStop() ? 0 : -1}
|
|
89
113
|
data-testid={props.testIdPrefix ? `${props.testIdPrefix}-${opt.value}` : undefined}
|
|
90
|
-
onClick={() =>
|
|
91
|
-
|
|
114
|
+
onClick={() => {
|
|
115
|
+
if (opt.disabled) return;
|
|
116
|
+
props.onChange(opt.value);
|
|
117
|
+
}}
|
|
118
|
+
class="px-3 py-1.5 text-xs transition-colors"
|
|
92
119
|
classList={{
|
|
93
120
|
capitalize: opt.capitalize,
|
|
94
|
-
"
|
|
121
|
+
"cursor-not-allowed": opt.disabled,
|
|
122
|
+
"cursor-pointer": !opt.disabled,
|
|
123
|
+
"bg-[var(--ks-accent,#fbbf24)]/20 text-[var(--ks-accent,#fbbf24)]": selected() && !opt.disabled,
|
|
124
|
+
"text-[var(--ks-fg-subtle,#71717a)]": opt.disabled,
|
|
95
125
|
"text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
|
|
96
|
-
!selected(),
|
|
126
|
+
!selected() && !opt.disabled,
|
|
97
127
|
}}
|
|
98
128
|
>
|
|
99
129
|
{opt.label}
|
|
130
|
+
{opt.disabled && opt.disabledNote ? (
|
|
131
|
+
<span id={noteId} class="sr-only">
|
|
132
|
+
{opt.disabledNote}
|
|
133
|
+
</span>
|
|
134
|
+
) : null}
|
|
100
135
|
</button>
|
|
101
136
|
);
|
|
102
137
|
}}
|
|
@@ -118,6 +118,45 @@ describe("FlowGraph", () => {
|
|
|
118
118
|
expect(canvas.getByTestId("c-svg").getAttribute("width")).toBe("100%");
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// Regression for a class-name-only token audit missing an attribute: the
|
|
122
|
+
// marker <path> fill and .ksui-fg-card background/border are SVG/CSS-in-JS
|
|
123
|
+
// strings, not Tailwind classes, so a scan that only greps class names for
|
|
124
|
+
// bare rgba()/hex literals walks right past them. jsdom doesn't compute
|
|
125
|
+
// SVG presentation-attribute styles, so this asserts on the rendered `fill`
|
|
126
|
+
// attribute plus the injected <style> source — both must route through the
|
|
127
|
+
// var(--ksui-fg-*/--ks-*) chain, never a bare rgba(255,255,255,...) literal.
|
|
128
|
+
it("routes the arrowhead marker fill and card background through CSS custom props, not a bare literal", async () => {
|
|
129
|
+
// injectCSS dedupes per-process (module-level Set in utils/inject-css),
|
|
130
|
+
// so a PRIOR test's render already consumed the injection — reset the
|
|
131
|
+
// module registry and re-import fresh so this test's render leaves an
|
|
132
|
+
// inspectable <style> tag regardless of suite order.
|
|
133
|
+
vi.resetModules();
|
|
134
|
+
const { default: FreshFlowGraph } = await import("./FlowGraph");
|
|
135
|
+
|
|
136
|
+
const { container } = render(() => (
|
|
137
|
+
<FreshFlowGraph
|
|
138
|
+
testId="fg"
|
|
139
|
+
nodes={[{ id: "a", label: "A" }, { id: "b", label: "B" }]}
|
|
140
|
+
edges={[{ from: "a", to: "b" }]}
|
|
141
|
+
/>
|
|
142
|
+
));
|
|
143
|
+
|
|
144
|
+
const markerPath = container.querySelector("marker#ksui-fg-arrow path");
|
|
145
|
+
const fill = markerPath?.getAttribute("fill") ?? "";
|
|
146
|
+
expect(fill).toMatch(/^var\(--ksui-fg-edge,/);
|
|
147
|
+
expect(fill).not.toMatch(/rgba\(\s*255\s*,\s*255\s*,\s*255/);
|
|
148
|
+
|
|
149
|
+
// jsdom can't compute SVG/CSS custom-property values, so assert on the
|
|
150
|
+
// injected <style> tag's rendered source instead of a computed style.
|
|
151
|
+
const styleEl = document.getElementById("ksui-flow-graph-style");
|
|
152
|
+
const css = styleEl?.textContent ?? "";
|
|
153
|
+
expect(css).toContain(".ksui-fg-card{");
|
|
154
|
+
const cardRule = css.slice(css.indexOf(".ksui-fg-card{"));
|
|
155
|
+
expect(cardRule).toMatch(/background:var\(--ksui-fg-card,/);
|
|
156
|
+
expect(cardRule).toMatch(/border:1px solid var\(--ksui-fg-node-border,/);
|
|
157
|
+
expect(css).not.toMatch(/rgba\(\s*255\s*,\s*255\s*,\s*255/);
|
|
158
|
+
});
|
|
159
|
+
|
|
121
160
|
it("animates edges with the flow class only when animated", () => {
|
|
122
161
|
const { container } = render(() => (
|
|
123
162
|
<FlowGraph
|