@kahitsan/ksui 0.43.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.43.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 ProgressBar: Component<ProgressBarProps> = (props) => {
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">
@@ -264,6 +264,9 @@ export interface TransactionFormProps {
264
264
  transferFeeAmount: string;
265
265
  setTransferFeeAmount: (v: string) => void;
266
266
  allowTransferFee: boolean;
267
+ // Editing a legacy payable row keeps the payable pane; create flows opt out
268
+ // via allowPayable={false} now that payables live in the Payables module.
269
+ allowPayable?: boolean;
267
270
  pendingFiles: PendingFile[];
268
271
  setPendingFiles: (v: PendingFile[]) => void;
269
272
  existingAttachments?: TransactionAttachment[];
@@ -399,10 +402,11 @@ export default function TransactionForm(props: TransactionFormProps) {
399
402
  // SearchableSelect mount so the loading-state placeholder shows while the
400
403
  // per-tenant options are still in flight.
401
404
  const subcategoryOptionsReady = () => subcategoryOptions() !== undefined;
402
- const categoryOptions = () =>
403
- props.category === "payable"
404
- ? ["sale", "expense", "business", "payable"]
405
- : ["sale", "expense", "business"];
405
+ const categoryOptions = () => {
406
+ if (props.category === "payable") return ["sale", "expense", "business", "payable"];
407
+ const base = ["sale", "expense", "business"];
408
+ return props.allowPayable === false ? base : [...base, "payable"];
409
+ };
406
410
 
407
411
  createEffect(() => {
408
412
  if (props.category !== "business" && props.transferFeeEnabled) {
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