@kahitsan/ksui 0.44.0 → 0.45.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/ActionMenu.test.tsx +84 -0
- package/src/components/base/ActionMenu.tsx +201 -0
- package/src/components/base/ModalHeader.test.tsx +35 -0
- package/src/components/base/ModalHeader.tsx +45 -0
- package/src/components/base/MultiSelectGroup.test.tsx +14 -0
- package/src/components/base/MultiSelectGroup.tsx +5 -1
- package/src/components/base/ProgressBar.test.tsx +52 -0
- package/src/components/base/ProgressBar.tsx +62 -1
- package/src/components/base/SegmentedFilter.test.tsx +10 -1
- package/src/components/base/SegmentedFilter.tsx +9 -6
- package/src/index.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import Pencil from "lucide-solid/icons/pencil";
|
|
4
|
+
import ActionMenu, { RowMenu } from "./ActionMenu";
|
|
5
|
+
|
|
6
|
+
const items = [
|
|
7
|
+
{ id: "open", label: "Open", icon: Pencil },
|
|
8
|
+
{ id: "disabled", label: "Unavailable", disabled: true },
|
|
9
|
+
{ id: "remove", label: "Remove", danger: true, separatorBefore: true },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
async function flushMenu() {
|
|
13
|
+
await Promise.resolve();
|
|
14
|
+
await Promise.resolve();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("ActionMenu", () => {
|
|
18
|
+
it("opens an accessible menu, focuses the first enabled item, and selects", async () => {
|
|
19
|
+
const onSelect = vi.fn();
|
|
20
|
+
render(() => <ActionMenu label="Row actions" items={items} onSelect={onSelect} testId="row-menu" />);
|
|
21
|
+
const trigger = screen.getByTestId("row-menu");
|
|
22
|
+
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
|
23
|
+
await fireEvent.click(trigger);
|
|
24
|
+
await flushMenu();
|
|
25
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
26
|
+
expect(screen.getByRole("menu", { name: "Row actions" })).toBeTruthy();
|
|
27
|
+
expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "Open" }));
|
|
28
|
+
expect(screen.getByRole("menuitem", { name: "Open" }).querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
|
|
29
|
+
await fireEvent.click(screen.getByRole("menuitem", { name: "Open" }));
|
|
30
|
+
expect(onSelect).toHaveBeenCalledWith("open");
|
|
31
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
32
|
+
await flushMenu();
|
|
33
|
+
expect(document.activeElement).toBe(trigger);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps focus moved by selection callback", async () => {
|
|
37
|
+
render(() => (
|
|
38
|
+
<div>
|
|
39
|
+
<ActionMenu
|
|
40
|
+
label="Actions"
|
|
41
|
+
items={items}
|
|
42
|
+
onSelect={() => screen.getByRole("button", { name: "Destination" }).focus()}
|
|
43
|
+
/>
|
|
44
|
+
<button type="button">Destination</button>
|
|
45
|
+
</div>
|
|
46
|
+
));
|
|
47
|
+
await fireEvent.click(screen.getByRole("button", { name: "Actions" }));
|
|
48
|
+
await flushMenu();
|
|
49
|
+
await fireEvent.click(screen.getByRole("menuitem", { name: "Open" }));
|
|
50
|
+
await flushMenu();
|
|
51
|
+
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Destination" }));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("skips disabled items during arrow navigation and restores trigger focus on Escape", async () => {
|
|
55
|
+
render(() => <ActionMenu label="Actions" items={items} onSelect={() => {}} />);
|
|
56
|
+
const trigger = screen.getByRole("button", { name: "Actions" });
|
|
57
|
+
await fireEvent.keyDown(trigger, { key: "ArrowDown" });
|
|
58
|
+
await flushMenu();
|
|
59
|
+
const open = screen.getByRole("menuitem", { name: "Open" });
|
|
60
|
+
const remove = screen.getByRole("menuitem", { name: "Remove" });
|
|
61
|
+
expect(document.activeElement).toBe(open);
|
|
62
|
+
await fireEvent.keyDown(open, { key: "ArrowDown" });
|
|
63
|
+
expect(document.activeElement).toBe(remove);
|
|
64
|
+
expect((screen.getByRole("menuitem", { name: "Unavailable" }) as HTMLButtonElement).disabled).toBe(true);
|
|
65
|
+
await fireEvent.keyDown(remove, { key: "Escape" });
|
|
66
|
+
await flushMenu();
|
|
67
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
68
|
+
expect(document.activeElement).toBe(trigger);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("closes on outside press and exposes RowMenu as the same reusable component", async () => {
|
|
72
|
+
render(() => (
|
|
73
|
+
<div>
|
|
74
|
+
<RowMenu label="More actions" items={items} onSelect={() => {}} />
|
|
75
|
+
<button type="button">Outside</button>
|
|
76
|
+
</div>
|
|
77
|
+
));
|
|
78
|
+
await fireEvent.click(screen.getByRole("button", { name: "More actions" }));
|
|
79
|
+
await flushMenu();
|
|
80
|
+
expect(screen.getByRole("menu")).toBeTruthy();
|
|
81
|
+
await fireEvent.mouseDown(screen.getByRole("button", { name: "Outside" }));
|
|
82
|
+
expect(screen.queryByRole("menu")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { For, Show, createSignal, createUniqueId, onCleanup, onMount, type Component, type JSX } from "solid-js";
|
|
2
|
+
import { Dynamic, Portal } from "solid-js/web";
|
|
3
|
+
import MoreHorizontal from "lucide-solid/icons/more-horizontal";
|
|
4
|
+
import { injectCSS } from "../../utils/inject-css";
|
|
5
|
+
import { usePopoverMount } from "../../utils/modal-layer";
|
|
6
|
+
import { useTopLayer } from "../../utils/top-layer";
|
|
7
|
+
|
|
8
|
+
const STYLE_ID = "ksui-action-menu-style";
|
|
9
|
+
const STYLE_CSS = `
|
|
10
|
+
.ksui-action-menu{position:relative;display:inline-block;}
|
|
11
|
+
.ksui-action-menu__trigger{display:inline-flex;width:2rem;height:2rem;align-items:center;justify-content:center;border:0;border-radius:.375rem;background:transparent;color:var(--ksui-action-menu-muted,var(--ks-fg-muted,#a1a1aa));cursor:pointer;transition:background-color .15s ease,color .15s ease;}
|
|
12
|
+
.ksui-action-menu__trigger:hover,.ksui-action-menu__trigger[aria-expanded="true"]{background:var(--ksui-action-menu-hover,var(--ks-surface-raised,#1a1a1a));color:var(--ksui-action-menu-fg,var(--ks-fg,#ffffff));}
|
|
13
|
+
.ksui-action-menu__trigger:focus-visible,.ksui-action-menu__item:focus-visible{outline:2px solid var(--ksui-action-menu-focus,var(--ks-focus-ring,#c9a961));outline-offset:2px;}
|
|
14
|
+
.ksui-action-menu__panel{position:fixed;z-index:70;min-width:11rem;overflow:hidden;border:1px solid var(--ksui-action-menu-border,var(--ks-border,rgba(39,39,42,0.5)));border-radius:.5rem;background:var(--ksui-action-menu-bg,var(--ks-surface,#0f0f0f));padding:.25rem 0;box-shadow:var(--ksui-action-menu-shadow,var(--ks-shadow-lg,0 12px 32px rgba(0,0,0,0.6)));}
|
|
15
|
+
.ksui-action-menu__item{display:flex;width:100%;align-items:center;gap:.5rem;border:0;background:transparent;padding:.375rem .75rem;color:var(--ksui-action-menu-fg,var(--ks-fg,#ffffff));font:inherit;font-size:.875rem;line-height:1.25rem;text-align:left;cursor:pointer;transition:background-color .15s ease,color .15s ease;}
|
|
16
|
+
.ksui-action-menu__item:hover,.ksui-action-menu__item:focus{background:var(--ksui-action-menu-hover,var(--ks-surface-raised,#1a1a1a));outline:none;}
|
|
17
|
+
.ksui-action-menu__item--danger{color:var(--ksui-action-menu-danger,var(--ks-danger-fg,#f87171));}
|
|
18
|
+
.ksui-action-menu__item--danger:hover,.ksui-action-menu__item--danger:focus{background:var(--ksui-action-menu-danger-bg,var(--ks-danger-bg,rgba(239,68,68,0.12)));}
|
|
19
|
+
.ksui-action-menu__item:disabled{cursor:not-allowed;opacity:.5;}
|
|
20
|
+
.ksui-action-menu__item-icon{display:inline-flex;flex:none;}
|
|
21
|
+
.ksui-action-menu__separator{margin:.25rem 0;border-top:1px solid var(--ksui-action-menu-border,var(--ks-border,rgba(39,39,42,0.5)));}
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export type ActionMenuIcon = Component<{ size?: number; class?: string }>;
|
|
25
|
+
export interface ActionMenuItem {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
icon?: ActionMenuIcon;
|
|
29
|
+
danger?: boolean;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
separatorBefore?: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface ActionMenuProps {
|
|
34
|
+
label: string;
|
|
35
|
+
items: readonly ActionMenuItem[];
|
|
36
|
+
onSelect: (id: string) => void;
|
|
37
|
+
icon?: ActionMenuIcon;
|
|
38
|
+
align?: "start" | "end";
|
|
39
|
+
class?: string;
|
|
40
|
+
triggerClass?: string;
|
|
41
|
+
menuClass?: string;
|
|
42
|
+
testId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nextMenuItemIndex(key: string, current: number, length: number): number | undefined {
|
|
46
|
+
switch (key) {
|
|
47
|
+
case "ArrowDown":
|
|
48
|
+
return current < 0 ? 0 : (current + 1) % length;
|
|
49
|
+
case "ArrowUp":
|
|
50
|
+
return current < 0 ? length - 1 : (current - 1 + length) % length;
|
|
51
|
+
case "Home":
|
|
52
|
+
return 0;
|
|
53
|
+
case "End":
|
|
54
|
+
return length - 1;
|
|
55
|
+
default:
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default function ActionMenu(props: ActionMenuProps): JSX.Element {
|
|
61
|
+
injectCSS(STYLE_ID, STYLE_CSS);
|
|
62
|
+
const mount = usePopoverMount();
|
|
63
|
+
const menuId = createUniqueId();
|
|
64
|
+
const [open, setOpen] = createSignal(false);
|
|
65
|
+
const [position, setPosition] = createSignal({ top: 0, left: 0 });
|
|
66
|
+
let container: HTMLDivElement | undefined;
|
|
67
|
+
let trigger: HTMLButtonElement | undefined;
|
|
68
|
+
let menu: HTMLDivElement | undefined;
|
|
69
|
+
const TriggerIcon = () => props.icon ?? MoreHorizontal;
|
|
70
|
+
const enabledItems = () => [...(menu?.querySelectorAll<HTMLButtonElement>("[role='menuitem']:not(:disabled)") ?? [])];
|
|
71
|
+
const focusItem = (edge: "first" | "last") => queueMicrotask(() => {
|
|
72
|
+
const items = enabledItems();
|
|
73
|
+
(edge === "first" ? items[0] : items[items.length - 1])?.focus();
|
|
74
|
+
});
|
|
75
|
+
const updatePosition = () => {
|
|
76
|
+
if (!trigger) return;
|
|
77
|
+
const rect = trigger.getBoundingClientRect();
|
|
78
|
+
const width = menu?.offsetWidth || 176;
|
|
79
|
+
const height = menu?.offsetHeight || 0;
|
|
80
|
+
const left = props.align === "start" ? rect.left : rect.right - width;
|
|
81
|
+
const top = height > 0 && rect.bottom + 4 + height > window.innerHeight
|
|
82
|
+
? Math.max(8, rect.top - height - 4)
|
|
83
|
+
: rect.bottom + 4;
|
|
84
|
+
setPosition({ top, left: Math.max(8, Math.min(left, window.innerWidth - width - 8)) });
|
|
85
|
+
};
|
|
86
|
+
const close = (restoreFocus = false) => {
|
|
87
|
+
setOpen(false);
|
|
88
|
+
if (restoreFocus) queueMicrotask(() => trigger?.focus());
|
|
89
|
+
};
|
|
90
|
+
const openMenu = (edge: "first" | "last" = "first") => {
|
|
91
|
+
setOpen(true);
|
|
92
|
+
queueMicrotask(() => {
|
|
93
|
+
updatePosition();
|
|
94
|
+
focusItem(edge);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
|
98
|
+
if (!open() || event.key !== "Escape") return;
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
close(true);
|
|
101
|
+
};
|
|
102
|
+
const onDocumentPointerDown = (event: MouseEvent) => {
|
|
103
|
+
const target = event.target as Node;
|
|
104
|
+
if (container?.contains(target) || menu?.contains(target)) return;
|
|
105
|
+
close();
|
|
106
|
+
};
|
|
107
|
+
const onMenuKeyDown = (event: KeyboardEvent) => {
|
|
108
|
+
const items = enabledItems();
|
|
109
|
+
if (event.key === "Tab") {
|
|
110
|
+
close();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (items.length === 0) return;
|
|
114
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
115
|
+
const next = nextMenuItemIndex(event.key, current, items.length);
|
|
116
|
+
if (next === undefined) return;
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
items[next]?.focus();
|
|
119
|
+
};
|
|
120
|
+
onMount(() => {
|
|
121
|
+
document.addEventListener("keydown", onDocumentKeyDown);
|
|
122
|
+
document.addEventListener("mousedown", onDocumentPointerDown);
|
|
123
|
+
window.addEventListener("resize", updatePosition);
|
|
124
|
+
window.addEventListener("scroll", updatePosition, true);
|
|
125
|
+
});
|
|
126
|
+
onCleanup(() => {
|
|
127
|
+
document.removeEventListener("keydown", onDocumentKeyDown);
|
|
128
|
+
document.removeEventListener("mousedown", onDocumentPointerDown);
|
|
129
|
+
window.removeEventListener("resize", updatePosition);
|
|
130
|
+
window.removeEventListener("scroll", updatePosition, true);
|
|
131
|
+
});
|
|
132
|
+
return (
|
|
133
|
+
<div ref={container} class={`ksui-action-menu ${props.class ?? ""}`}>
|
|
134
|
+
<button
|
|
135
|
+
ref={trigger}
|
|
136
|
+
type="button"
|
|
137
|
+
aria-label={props.label}
|
|
138
|
+
aria-haspopup="menu"
|
|
139
|
+
aria-expanded={open()}
|
|
140
|
+
aria-controls={open() ? menuId : undefined}
|
|
141
|
+
data-testid={props.testId}
|
|
142
|
+
class={`ksui-action-menu__trigger ${props.triggerClass ?? ""}`}
|
|
143
|
+
onClick={() => open() ? close() : openMenu()}
|
|
144
|
+
onKeyDown={(event) => {
|
|
145
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
openMenu(event.key === "ArrowUp" ? "last" : "first");
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<Dynamic component={TriggerIcon()} size={16} aria-hidden="true" />
|
|
152
|
+
</button>
|
|
153
|
+
<Show when={open()}>
|
|
154
|
+
<Portal mount={mount()}>
|
|
155
|
+
<div
|
|
156
|
+
ref={(element) => {
|
|
157
|
+
menu = element;
|
|
158
|
+
onCleanup(useTopLayer(element));
|
|
159
|
+
queueMicrotask(updatePosition);
|
|
160
|
+
}}
|
|
161
|
+
id={menuId}
|
|
162
|
+
role="menu"
|
|
163
|
+
aria-label={props.label}
|
|
164
|
+
class={`ksui-action-menu__panel ${props.menuClass ?? ""}`}
|
|
165
|
+
style={{ top: `${position().top}px`, left: `${position().left}px` }}
|
|
166
|
+
onKeyDown={onMenuKeyDown}
|
|
167
|
+
>
|
|
168
|
+
<For each={props.items}>{(item) => (
|
|
169
|
+
<>
|
|
170
|
+
<Show when={item.separatorBefore}><div role="separator" class="ksui-action-menu__separator" /></Show>
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
role="menuitem"
|
|
174
|
+
disabled={item.disabled}
|
|
175
|
+
class={`ksui-action-menu__item${item.danger ? " ksui-action-menu__item--danger" : ""}`}
|
|
176
|
+
onClick={() => {
|
|
177
|
+
if (item.disabled) return;
|
|
178
|
+
const selectedElement = document.activeElement;
|
|
179
|
+
setOpen(false);
|
|
180
|
+
props.onSelect(item.id);
|
|
181
|
+
queueMicrotask(() => {
|
|
182
|
+
const active = document.activeElement;
|
|
183
|
+
if (active === document.body || active === selectedElement) trigger?.focus();
|
|
184
|
+
});
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<Show when={item.icon}>{(Icon) => <span class="ksui-action-menu__item-icon"><Dynamic component={Icon()} size={14} aria-hidden="true" /></span>}</Show>
|
|
188
|
+
{item.label}
|
|
189
|
+
</button>
|
|
190
|
+
</>
|
|
191
|
+
)}</For>
|
|
192
|
+
</div>
|
|
193
|
+
</Portal>
|
|
194
|
+
</Show>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const RowMenu = ActionMenu;
|
|
200
|
+
export type RowMenuItem = ActionMenuItem;
|
|
201
|
+
export type RowMenuProps = ActionMenuProps;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import ModalHeader from "./ModalHeader";
|
|
4
|
+
|
|
5
|
+
describe("ModalHeader", () => {
|
|
6
|
+
it("renders title, subtitle, and an accessible close control", async () => {
|
|
7
|
+
const onClose = vi.fn();
|
|
8
|
+
render(() => <ModalHeader title="Edit schedule" subtitle="Future entries only" onClose={onClose} />);
|
|
9
|
+
expect(screen.getByRole("heading", { name: "Edit schedule", level: 2 })).toBeTruthy();
|
|
10
|
+
expect(screen.getByText("Future entries only")).toBeTruthy();
|
|
11
|
+
await fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
|
12
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders JSX slots through owner-safe functions", () => {
|
|
16
|
+
render(() => (
|
|
17
|
+
<ModalHeader
|
|
18
|
+
title={() => <label><span>Schedule name</span><input aria-label="Schedule name" /></label>}
|
|
19
|
+
subtitle={() => <strong>Recurring monthly</strong>}
|
|
20
|
+
onClose={() => {}}
|
|
21
|
+
/>
|
|
22
|
+
));
|
|
23
|
+
expect(screen.getByRole("textbox", { name: "Schedule name" })).toBeTruthy();
|
|
24
|
+
expect(screen.getByText("Recurring monthly")).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts custom title id, close label, and classes", () => {
|
|
28
|
+
const { container } = render(() => (
|
|
29
|
+
<ModalHeader title="Details" titleId="details-title" closeLabel="Close details" class="custom-header" onClose={() => {}} />
|
|
30
|
+
));
|
|
31
|
+
expect(screen.getByRole("heading").id).toBe("details-title");
|
|
32
|
+
expect(screen.getByRole("button", { name: "Close details" })).toBeTruthy();
|
|
33
|
+
expect(container.querySelector("header")?.classList.contains("custom-header")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Show, createUniqueId, type JSX } from "solid-js";
|
|
2
|
+
import X from "lucide-solid/icons/x";
|
|
3
|
+
import { injectCSS } from "../../utils/inject-css";
|
|
4
|
+
|
|
5
|
+
const STYLE_ID = "ksui-modal-header-style";
|
|
6
|
+
const STYLE_CSS = `
|
|
7
|
+
.ksui-modal-header{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:1px solid var(--ksui-modal-header-border,var(--ks-border,rgba(39,39,42,0.5)));}
|
|
8
|
+
.ksui-modal-header__copy{min-width:0;}
|
|
9
|
+
.ksui-modal-header__title{margin:0;overflow:hidden;color:var(--ksui-modal-header-fg,var(--ks-fg,#ffffff));font-size:1.125rem;line-height:1.75rem;font-weight:600;text-overflow:ellipsis;white-space:nowrap;}
|
|
10
|
+
.ksui-modal-header__subtitle{margin-top:.125rem;color:var(--ksui-modal-header-muted,var(--ks-fg-muted,#a1a1aa));font-size:.75rem;line-height:1rem;}
|
|
11
|
+
.ksui-modal-header__close{display:inline-flex;width:2rem;height:2rem;flex:none;align-items:center;justify-content:center;border:0;border-radius:.375rem;background:transparent;color:var(--ksui-modal-header-muted,var(--ks-fg-muted,#a1a1aa));cursor:pointer;transition:background-color .15s ease,color .15s ease;}
|
|
12
|
+
.ksui-modal-header__close:hover{background:var(--ksui-modal-header-hover,var(--ks-surface-raised,#1a1a1a));color:var(--ksui-modal-header-fg,var(--ks-fg,#ffffff));}
|
|
13
|
+
.ksui-modal-header__close:focus-visible{outline:2px solid var(--ksui-modal-header-focus,var(--ks-focus-ring,#c9a961));outline-offset:2px;}
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
export interface ModalHeaderProps {
|
|
17
|
+
title: string | (() => JSX.Element);
|
|
18
|
+
subtitle?: string | (() => JSX.Element);
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
closeLabel?: string;
|
|
21
|
+
titleId?: string;
|
|
22
|
+
class?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderContent(content: string | (() => JSX.Element)): JSX.Element {
|
|
26
|
+
return typeof content === "function" ? content() : content;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function ModalHeader(props: ModalHeaderProps): JSX.Element {
|
|
30
|
+
injectCSS(STYLE_ID, STYLE_CSS);
|
|
31
|
+
const generatedId = createUniqueId();
|
|
32
|
+
return (
|
|
33
|
+
<header class={`ksui-modal-header ${props.class ?? ""}`}>
|
|
34
|
+
<div class="ksui-modal-header__copy">
|
|
35
|
+
<h2 id={props.titleId ?? generatedId} class="ksui-modal-header__title">{renderContent(props.title)}</h2>
|
|
36
|
+
<Show when={props.subtitle}>
|
|
37
|
+
<div class="ksui-modal-header__subtitle">{renderContent(props.subtitle!)}</div>
|
|
38
|
+
</Show>
|
|
39
|
+
</div>
|
|
40
|
+
<button type="button" aria-label={props.closeLabel ?? "Close"} class="ksui-modal-header__close" onClick={props.onClose}>
|
|
41
|
+
<X size={16} aria-hidden="true" />
|
|
42
|
+
</button>
|
|
43
|
+
</header>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -39,3 +39,17 @@ describe("MultiSelectGroup", () => {
|
|
|
39
39
|
expect(onChange).not.toHaveBeenCalled();
|
|
40
40
|
});
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
describe("MultiSelectGroup variants", () => {
|
|
44
|
+
it("keeps the existing info selected treatment by default", () => {
|
|
45
|
+
render(() => <MultiSelectGroup ariaLabel="Days" options={options} value={["mon"]} onChange={() => {}} />);
|
|
46
|
+
expect(screen.getByRole("button", { name: "Mon" }).className).toContain("--ks-info");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("uses the accent selected-state treatment when requested", () => {
|
|
50
|
+
render(() => <MultiSelectGroup ariaLabel="Days" options={options} value={["mon"]} onChange={() => {}} variant="accent" />);
|
|
51
|
+
const selected = screen.getByRole("button", { name: "Mon" });
|
|
52
|
+
expect(selected.className).toContain("--ks-accent");
|
|
53
|
+
expect(selected.className).not.toContain("--ks-info");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -7,6 +7,7 @@ export interface MultiSelectOption {
|
|
|
7
7
|
disabledNote?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export type MultiSelectGroupVariant = "info" | "accent";
|
|
10
11
|
export interface MultiSelectGroupProps {
|
|
11
12
|
options: MultiSelectOption[];
|
|
12
13
|
value: readonly string[];
|
|
@@ -15,6 +16,8 @@ export interface MultiSelectGroupProps {
|
|
|
15
16
|
class?: string;
|
|
16
17
|
itemClass?: string;
|
|
17
18
|
disabled?: boolean;
|
|
19
|
+
/** Selected-state palette. Defaults to the existing info treatment. */
|
|
20
|
+
variant?: MultiSelectGroupVariant;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export default function MultiSelectGroup(props: MultiSelectGroupProps): JSX.Element {
|
|
@@ -39,7 +42,8 @@ export default function MultiSelectGroup(props: MultiSelectGroupProps): JSX.Elem
|
|
|
39
42
|
onClick={() => toggle(option)}
|
|
40
43
|
class={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${props.itemClass ?? ""}`}
|
|
41
44
|
classList={{
|
|
42
|
-
"border-[var(--ks-info,#38bdf8)] bg-[var(--ks-info,#38bdf8)] text-[var(--ks-info-fg,#7dd3fc)]": selected(option.value),
|
|
45
|
+
"border-[var(--ks-info,#38bdf8)] bg-[var(--ks-info,#38bdf8)] text-[var(--ks-info-fg,#7dd3fc)]": selected(option.value) && (props.variant ?? "info") === "info",
|
|
46
|
+
"border-[var(--ks-accent,#fbbf24)] bg-[var(--ks-accent,#fbbf24)] font-semibold text-[var(--ks-fg-on-accent,#0a0a0a)]": selected(option.value) && props.variant === "accent",
|
|
43
47
|
"border-[var(--ks-border,rgba(39,39,42,0.5))] bg-[var(--ks-surface,#0f0f0f)] text-[var(--ks-fg-muted,#a1a1aa)] hover:border-[var(--ks-border-strong,#3f3f46)] hover:text-[var(--ks-fg,#ffffff)]": !selected(option.value),
|
|
44
48
|
"cursor-not-allowed opacity-50": !!props.disabled || !!option.disabled,
|
|
45
49
|
}}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render, screen } from "@solidjs/testing-library";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import ProgressBar from "./ProgressBar";
|
|
4
|
+
|
|
5
|
+
describe("ProgressBar", () => {
|
|
6
|
+
it("preserves the existing full-height default variant", () => {
|
|
7
|
+
const { container } = render(() => <ProgressBar progress={35} label="Imported" />);
|
|
8
|
+
const root = container.firstElementChild as HTMLElement;
|
|
9
|
+
expect(root.className).toContain("h-8");
|
|
10
|
+
expect(root.getAttribute("role")).toBeNull();
|
|
11
|
+
expect(root.textContent).toContain("35%");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders the compact labeled variant with progress semantics", () => {
|
|
15
|
+
render(() => <ProgressBar variant="compact" progress={42.4} label="2 / 5 paid" />);
|
|
16
|
+
const progress = screen.getByRole("progressbar", { name: "2 / 5 paid" });
|
|
17
|
+
expect(progress.getAttribute("aria-valuenow")).toBe("42");
|
|
18
|
+
expect(progress.textContent).toContain("2 / 5 paid");
|
|
19
|
+
expect(progress.textContent).toContain("42%");
|
|
20
|
+
expect(progress.querySelector<HTMLElement>('[style*="width"]')?.style.width).toBe("42%");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("clamps compact progress and supports a custom right label and color", () => {
|
|
24
|
+
render(() => <ProgressBar variant="compact" progress={140} label="Complete" rightLabel="Done" color="green" />);
|
|
25
|
+
const progress = screen.getByRole("progressbar", { name: "Complete" });
|
|
26
|
+
expect(progress.getAttribute("aria-valuenow")).toBe("100");
|
|
27
|
+
expect(progress.textContent).toContain("Done");
|
|
28
|
+
const fill = progress.querySelector<HTMLElement>('[style*="width"]')!;
|
|
29
|
+
expect(fill.style.width).toBe("100%");
|
|
30
|
+
expect(fill.style.backgroundColor).toContain("--ks-success");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("honors compact visibility, direction, icon, and shimmer props", () => {
|
|
34
|
+
const Icon = () => <svg data-testid="compact-icon" />;
|
|
35
|
+
const { container } = render(() => (
|
|
36
|
+
<ProgressBar
|
|
37
|
+
variant="compact"
|
|
38
|
+
progress={25}
|
|
39
|
+
label="Quarter"
|
|
40
|
+
hidePercentage
|
|
41
|
+
position="right"
|
|
42
|
+
shimmer
|
|
43
|
+
icon={Icon}
|
|
44
|
+
/>
|
|
45
|
+
));
|
|
46
|
+
expect(screen.getByTestId("compact-icon")).toBeTruthy();
|
|
47
|
+
expect(screen.queryByText("25%")).toBeNull();
|
|
48
|
+
const fill = container.querySelector<HTMLElement>('[style*="width"]')!;
|
|
49
|
+
expect(fill.style.marginLeft).toBe("auto");
|
|
50
|
+
expect(fill.querySelector(".ks-progress-shimmer")).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -35,6 +35,7 @@ const styles: Record<string, string> = {
|
|
|
35
35
|
// ProgressColor is derived from COLOR_MAP so adding a hue later flows here
|
|
36
36
|
// without re-syncing a hardcoded list.
|
|
37
37
|
export type ProgressColor = keyof typeof COLOR_MAP;
|
|
38
|
+
export type ProgressBarVariant = "default" | "compact";
|
|
38
39
|
|
|
39
40
|
export interface ProgressBarProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
40
41
|
progress: number;
|
|
@@ -52,6 +53,8 @@ export interface ProgressBarProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
|
52
53
|
// Explicit color signal — class-substring sniffing broke when COLOR_AMBER
|
|
53
54
|
// was tokenized (4f6ed40 dropped the literal "amber" from the class).
|
|
54
55
|
color?: ProgressColor;
|
|
56
|
+
/** Compact labeled readout with a thin track. Defaults to the existing bar. */
|
|
57
|
+
variant?: ProgressBarVariant;
|
|
55
58
|
class?: string;
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -159,7 +162,7 @@ function extractTextSize(className: string): number {
|
|
|
159
162
|
return 14;
|
|
160
163
|
}
|
|
161
164
|
|
|
162
|
-
const
|
|
165
|
+
const DefaultProgressBar: Component<ProgressBarProps> = (props) => {
|
|
163
166
|
injectCSS(PROGRESS_STYLE_ID, PROGRESS_CSS);
|
|
164
167
|
const [local, others] = splitProps(props, [
|
|
165
168
|
"progress",
|
|
@@ -171,6 +174,7 @@ const ProgressBar: Component<ProgressBarProps> = (props) => {
|
|
|
171
174
|
"hidePercentage",
|
|
172
175
|
"rightLabel",
|
|
173
176
|
"color",
|
|
177
|
+
"variant",
|
|
174
178
|
"class",
|
|
175
179
|
]);
|
|
176
180
|
|
|
@@ -342,4 +346,61 @@ const ProgressBar: Component<ProgressBarProps> = (props) => {
|
|
|
342
346
|
);
|
|
343
347
|
};
|
|
344
348
|
|
|
349
|
+
type CompactProgressBarProps = ProgressBarProps;
|
|
350
|
+
|
|
351
|
+
const CompactProgressBar: Component<CompactProgressBarProps> = (props) => {
|
|
352
|
+
injectCSS(PROGRESS_STYLE_ID, PROGRESS_CSS);
|
|
353
|
+
const [local, others] = splitProps(props, ["progress", "icon", "label", "statusLabel", "shimmer", "position", "hidePercentage", "rightLabel", "color", "variant", "class"]);
|
|
354
|
+
const value = createMemo(() => {
|
|
355
|
+
if (typeof local.progress !== "number" || Number.isNaN(local.progress)) return 0;
|
|
356
|
+
return Math.min(100, Math.max(0, Math.round(local.progress)));
|
|
357
|
+
});
|
|
358
|
+
const fillColor = createMemo(() => local.color ? COLOR_MAP[local.color].indicator : "var(--ks-primary, #c9a961)");
|
|
359
|
+
const accessibleLabel = () => local.label ?? local.statusLabel ?? "Progress";
|
|
360
|
+
const Icon = () => local.icon;
|
|
361
|
+
return (
|
|
362
|
+
<div
|
|
363
|
+
{...others}
|
|
364
|
+
class={cn("min-w-28", local.class)}
|
|
365
|
+
role="progressbar"
|
|
366
|
+
aria-label={accessibleLabel()}
|
|
367
|
+
aria-valuenow={value()}
|
|
368
|
+
aria-valuemin={0}
|
|
369
|
+
aria-valuemax={100}
|
|
370
|
+
>
|
|
371
|
+
<div class="mb-1 flex items-center justify-between gap-2 text-[10px] leading-none">
|
|
372
|
+
<span class="flex min-w-0 items-center gap-1 truncate font-semibold text-[var(--ks-fg,#ffffff)]">
|
|
373
|
+
<Show when={Icon()}>{(ResolvedIcon) => <Dynamic component={ResolvedIcon()} size={12} aria-hidden="true" />}</Show>
|
|
374
|
+
<span class="truncate">{local.label ?? local.statusLabel}</span>
|
|
375
|
+
</span>
|
|
376
|
+
<Show when={local.rightLabel !== undefined || !local.hidePercentage}>
|
|
377
|
+
<span class="shrink-0 font-mono tabular-nums text-[var(--ks-fg-muted,#a1a1aa)]">
|
|
378
|
+
{local.rightLabel ?? `${value()}%`}
|
|
379
|
+
</span>
|
|
380
|
+
</Show>
|
|
381
|
+
</div>
|
|
382
|
+
<div class="h-[5px] overflow-hidden rounded-full bg-[var(--ks-surface-raised,#1a1a1a)]">
|
|
383
|
+
<div
|
|
384
|
+
class="relative h-full overflow-hidden rounded-full transition-[width] duration-200"
|
|
385
|
+
style={{
|
|
386
|
+
width: `${value()}%`,
|
|
387
|
+
"background-color": fillColor(),
|
|
388
|
+
"margin-left": local.position === "right" ? "auto" : undefined,
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
<Show when={local.shimmer}>
|
|
392
|
+
<div class={styles["animate-shimmer"]} />
|
|
393
|
+
</Show>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const ProgressBar: Component<ProgressBarProps> = (props) => (
|
|
401
|
+
<Show when={props.variant === "compact"} fallback={<DefaultProgressBar {...props} />}>
|
|
402
|
+
<CompactProgressBar {...props} />
|
|
403
|
+
</Show>
|
|
404
|
+
);
|
|
405
|
+
|
|
345
406
|
export default ProgressBar;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { render, fireEvent } from "@solidjs/testing-library";
|
|
3
|
+
import List from "lucide-solid/icons/list";
|
|
3
4
|
import SegmentedFilter, { type SegmentedFilterOption } from "./SegmentedFilter";
|
|
4
5
|
|
|
5
6
|
// Back-compat: bare-string options are still accepted alongside the object
|
|
@@ -7,7 +8,7 @@ import SegmentedFilter, { type SegmentedFilterOption } from "./SegmentedFilter";
|
|
|
7
8
|
const STRING_OPTIONS: SegmentedFilterOption[] = ["today", "week", "month"];
|
|
8
9
|
|
|
9
10
|
const OBJECT_OPTIONS: SegmentedFilterOption[] = [
|
|
10
|
-
{ value: "table", label: "Table" },
|
|
11
|
+
{ value: "table", label: "Table", icon: List },
|
|
11
12
|
{ value: "calendar", label: "Calendar" },
|
|
12
13
|
];
|
|
13
14
|
|
|
@@ -40,6 +41,14 @@ describe("SegmentedFilter", () => {
|
|
|
40
41
|
const radios = getAllByRole("radio");
|
|
41
42
|
expect(radios.map((r) => r.textContent)).toEqual(["Table", "Calendar"]);
|
|
42
43
|
});
|
|
44
|
+
it("renders optional decorative icons without changing accessible names", () => {
|
|
45
|
+
const { getAllByRole } = render(() => (
|
|
46
|
+
<SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={() => {}} />
|
|
47
|
+
));
|
|
48
|
+
const table = getAllByRole("radio")[0];
|
|
49
|
+
expect(table.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
|
|
50
|
+
expect(table.getAttribute("aria-checked")).toBe("true");
|
|
51
|
+
});
|
|
43
52
|
|
|
44
53
|
it("emits the clicked value on an enabled segment", () => {
|
|
45
54
|
const onChange = vi.fn();
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { For, type JSX } from "solid-js";
|
|
1
|
+
import { For, type Component, type JSX } from "solid-js";
|
|
2
|
+
import { Dynamic } from "solid-js/web";
|
|
3
|
+
export type SegmentedFilterIcon = Component<{ size?: number; class?: string }>;
|
|
2
4
|
|
|
3
5
|
/** One choice in the segmented row. A bare string uses the value as the label
|
|
4
6
|
* and is rendered capitalized; an object lets the caller supply an explicit
|
|
@@ -8,9 +10,9 @@ import { For, type JSX } from "solid-js";
|
|
|
8
10
|
* text, since a muted, unclickable control is otherwise unexplained. */
|
|
9
11
|
export type SegmentedFilterOption =
|
|
10
12
|
| string
|
|
11
|
-
| { value: string; label: string; disabled?: boolean; disabledNote?: string };
|
|
13
|
+
| { value: string; label: string; icon?: SegmentedFilterIcon; disabled?: boolean; disabledNote?: string };
|
|
12
14
|
|
|
13
|
-
interface SegmentedFilterProps {
|
|
15
|
+
export interface SegmentedFilterProps {
|
|
14
16
|
/** The available segments, left to right. */
|
|
15
17
|
options: SegmentedFilterOption[];
|
|
16
18
|
/** The currently active value. Matched against each option's value. */
|
|
@@ -37,8 +39,8 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
|
|
|
37
39
|
const buttonRefs: (HTMLButtonElement | undefined)[] = [];
|
|
38
40
|
const optionOf = (o: SegmentedFilterOption) =>
|
|
39
41
|
typeof o === "string"
|
|
40
|
-
? { value: o, label: o, capitalize: true, disabled: false, disabledNote: undefined as string | undefined }
|
|
41
|
-
: { disabled: false, disabledNote: undefined as string | undefined, ...o, capitalize: false };
|
|
42
|
+
? { value: o, label: o, icon: undefined as SegmentedFilterIcon | undefined, capitalize: true, disabled: false, disabledNote: undefined as string | undefined }
|
|
43
|
+
: { icon: undefined as SegmentedFilterIcon | undefined, disabled: false, disabledNote: undefined as string | undefined, ...o, capitalize: false };
|
|
42
44
|
|
|
43
45
|
const currentIndex = () => {
|
|
44
46
|
const i = props.options.findIndex((o) => optionOf(o).value === props.value);
|
|
@@ -117,7 +119,7 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
|
|
|
117
119
|
if (opt.disabled) return;
|
|
118
120
|
props.onChange(opt.value);
|
|
119
121
|
}}
|
|
120
|
-
class="shrink-0 px-3 py-1.5 text-xs transition-colors"
|
|
122
|
+
class="inline-flex shrink-0 items-center justify-center gap-1.5 px-3 py-1.5 text-xs transition-colors"
|
|
121
123
|
classList={{
|
|
122
124
|
capitalize: opt.capitalize,
|
|
123
125
|
"cursor-not-allowed": opt.disabled,
|
|
@@ -128,6 +130,7 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
|
|
|
128
130
|
!selected() && !opt.disabled,
|
|
129
131
|
}}
|
|
130
132
|
>
|
|
133
|
+
{opt.icon ? <Dynamic component={opt.icon} size={14} aria-hidden="true" /> : null}
|
|
131
134
|
{opt.label}
|
|
132
135
|
{opt.disabled && opt.disabledNote ? (
|
|
133
136
|
<span id={noteId} class="sr-only">
|
package/src/index.ts
CHANGED
|
@@ -53,14 +53,14 @@ export { default as DetailRow } from "./components/base/DetailRow";
|
|
|
53
53
|
export { default as Tooltip } from "./components/base/Tooltip";
|
|
54
54
|
export { default as ImageCropper } from "./components/base/ImageCropper";
|
|
55
55
|
export { default as ImageViewer } from "./components/base/ImageViewer";
|
|
56
|
-
export { default as ProgressBar, type ProgressBarProps } from "./components/base/ProgressBar";
|
|
56
|
+
export { default as ProgressBar, type ProgressBarProps, type ProgressBarVariant, type ProgressColor } from "./components/base/ProgressBar";
|
|
57
57
|
export { default as ChartLegend } from "./components/base/ChartLegend";
|
|
58
58
|
|
|
59
59
|
// Plugin reusability backlog: atoms migrated from per-plugin copies into the
|
|
60
60
|
// single canonical package.
|
|
61
61
|
export { default as StatusPill, type StatusTone } from "./components/base/StatusPill";
|
|
62
|
-
export { default as SegmentedFilter, type SegmentedFilterOption } from "./components/base/SegmentedFilter";
|
|
63
|
-
export { default as MultiSelectGroup, type MultiSelectGroupProps, type MultiSelectOption } from "./components/base/MultiSelectGroup";
|
|
62
|
+
export { default as SegmentedFilter, type SegmentedFilterProps, type SegmentedFilterOption, type SegmentedFilterIcon } from "./components/base/SegmentedFilter";
|
|
63
|
+
export { default as MultiSelectGroup, type MultiSelectGroupProps, type MultiSelectGroupVariant, type MultiSelectOption } from "./components/base/MultiSelectGroup";
|
|
64
64
|
export { default as CopyButton } from "./components/base/CopyButton";
|
|
65
65
|
export { default as KpiCard, type KpiCardProps, type KpiTone } from "./components/base/KpiCard";
|
|
66
66
|
export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
|
|
@@ -79,6 +79,16 @@ export { default as EyebrowBadge, type EyebrowBadgeProps, type EyebrowTone, type
|
|
|
79
79
|
// Self-contained modal dialog (promoted from the former host kit). Injects its
|
|
80
80
|
// own CSS; no Tailwind / host-brand classes required.
|
|
81
81
|
export { default as Modal, type ModalProps, type ModalSize, type ModalTone } from "./components/base/Modal";
|
|
82
|
+
export { default as ModalHeader, type ModalHeaderProps } from "./components/base/ModalHeader";
|
|
83
|
+
export {
|
|
84
|
+
default as ActionMenu,
|
|
85
|
+
RowMenu,
|
|
86
|
+
type ActionMenuProps,
|
|
87
|
+
type ActionMenuItem,
|
|
88
|
+
type ActionMenuIcon,
|
|
89
|
+
type RowMenuProps,
|
|
90
|
+
type RowMenuItem,
|
|
91
|
+
} from "./components/base/ActionMenu";
|
|
82
92
|
|
|
83
93
|
// Calendar date picker (ported from the host kit). Trigger button labeled with
|
|
84
94
|
// the selected date or "Pick date", a portaled calendar popover with prev/next
|