@kahitsan/ksui 0.41.0 → 0.42.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/DatePicker.test.tsx +16 -0
- package/src/components/base/DatePicker.tsx +3 -2
- package/src/components/base/MultiSelectGroup.test.tsx +41 -0
- package/src/components/base/MultiSelectGroup.tsx +53 -0
- package/src/components/composite/FormActions.tsx +4 -3
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kahitsan/ksui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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",
|
|
@@ -43,6 +43,22 @@ describe("DatePicker", () => {
|
|
|
43
43
|
expect(onChange).toHaveBeenCalledWith("2026-06-20");
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("keeps the trigger and clear action keyboard reachable", () => {
|
|
47
|
+
render(() => <DatePicker value="2026-06-15" onChange={() => {}} />);
|
|
48
|
+
const trigger = screen.getByText("Jun 15").closest("button")!;
|
|
49
|
+
expect(trigger.tabIndex).toBe(0);
|
|
50
|
+
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
|
51
|
+
const clear = screen.getByRole("button", { name: "Clear date" });
|
|
52
|
+
expect(clear.tabIndex).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
it("opens from native keyboard activation", async () => {
|
|
55
|
+
render(() => <DatePicker value={null} onChange={() => {}} />);
|
|
56
|
+
const trigger = screen.getByText("Pick date").closest("button")!;
|
|
57
|
+
trigger.focus();
|
|
58
|
+
await fireEvent.keyDown(trigger, { key: "Enter" });
|
|
59
|
+
await fireEvent.click(trigger);
|
|
60
|
+
expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
|
|
61
|
+
});
|
|
46
62
|
it("renders disabled trigger when disabled prop is true", () => {
|
|
47
63
|
render(() => <DatePicker value={null} onChange={() => {}} disabled />);
|
|
48
64
|
const trigger = screen.getByText("Pick date").closest("button")!;
|
|
@@ -724,7 +724,8 @@ export default function DatePicker(props: DatePickerProps) {
|
|
|
724
724
|
ref={triggerRef}
|
|
725
725
|
onClick={openPicker}
|
|
726
726
|
type="button"
|
|
727
|
-
|
|
727
|
+
aria-haspopup="dialog"
|
|
728
|
+
aria-expanded={open()}
|
|
728
729
|
disabled={props.disabled}
|
|
729
730
|
class={
|
|
730
731
|
props.triggerClass ??
|
|
@@ -736,7 +737,7 @@ export default function DatePicker(props: DatePickerProps) {
|
|
|
736
737
|
</button>
|
|
737
738
|
{/* Clear button */}
|
|
738
739
|
<Show when={hasValue()}>
|
|
739
|
-
<button type="button"
|
|
740
|
+
<button type="button" aria-label="Clear date" class="ksui-datepicker-clear-btn" onClick={clear}>
|
|
740
741
|
<X size={14} />
|
|
741
742
|
</button>
|
|
742
743
|
</Show>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@solidjs/testing-library";
|
|
2
|
+
import { createSignal } from "solid-js";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import MultiSelectGroup from "./MultiSelectGroup";
|
|
5
|
+
|
|
6
|
+
const options = [
|
|
7
|
+
{ value: "sun", label: "Sun" },
|
|
8
|
+
{ value: "mon", label: "Mon" },
|
|
9
|
+
{ value: "tue", label: "Tue" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe("MultiSelectGroup", () => {
|
|
13
|
+
it("adds and removes independent selections in option order", async () => {
|
|
14
|
+
const [value, setValue] = createSignal<string[]>(["tue"]);
|
|
15
|
+
render(() => <MultiSelectGroup ariaLabel="Repeat on" options={options} value={value()} onChange={setValue} />);
|
|
16
|
+
await fireEvent.click(screen.getByRole("button", { name: "Mon" }));
|
|
17
|
+
expect(value()).toEqual(["mon", "tue"]);
|
|
18
|
+
await fireEvent.click(screen.getByRole("button", { name: "Tue" }));
|
|
19
|
+
expect(value()).toEqual(["mon"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("exposes pressed state and native keyboard activation", async () => {
|
|
23
|
+
const onChange = vi.fn();
|
|
24
|
+
render(() => <MultiSelectGroup ariaLabel="Days" options={options} value={["mon"]} onChange={onChange} />);
|
|
25
|
+
expect(screen.getByRole("button", { name: "Mon" }).getAttribute("aria-pressed")).toBe("true");
|
|
26
|
+
const sunday = screen.getByRole("button", { name: "Sun" });
|
|
27
|
+
sunday.focus();
|
|
28
|
+
await fireEvent.keyDown(sunday, { key: "Enter" });
|
|
29
|
+
await fireEvent.click(sunday);
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith(["sun", "mon"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("blocks disabled options", async () => {
|
|
34
|
+
const onChange = vi.fn();
|
|
35
|
+
render(() => <MultiSelectGroup ariaLabel="Days" options={[{ value: "sun", label: "Sun", disabled: true }]} value={[]} onChange={onChange} />);
|
|
36
|
+
const button = screen.getByRole("button", { name: "Sun" }) as HTMLButtonElement;
|
|
37
|
+
expect(button.disabled).toBe(true);
|
|
38
|
+
await fireEvent.click(button);
|
|
39
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { For, type JSX } from "solid-js";
|
|
2
|
+
|
|
3
|
+
export interface MultiSelectOption {
|
|
4
|
+
value: string;
|
|
5
|
+
label: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
disabledNote?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MultiSelectGroupProps {
|
|
11
|
+
options: MultiSelectOption[];
|
|
12
|
+
value: readonly string[];
|
|
13
|
+
onChange: (value: string[]) => void;
|
|
14
|
+
ariaLabel: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
itemClass?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function MultiSelectGroup(props: MultiSelectGroupProps): JSX.Element {
|
|
21
|
+
const selected = (value: string) => props.value.includes(value);
|
|
22
|
+
const toggle = (option: MultiSelectOption) => {
|
|
23
|
+
if (props.disabled || option.disabled) return;
|
|
24
|
+
const next = selected(option.value)
|
|
25
|
+
? props.value.filter((value) => value !== option.value)
|
|
26
|
+
: props.options.filter((candidate) => candidate.value === option.value || props.value.includes(candidate.value)).map((candidate) => candidate.value);
|
|
27
|
+
props.onChange(next);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div role="group" aria-label={props.ariaLabel} class={`flex flex-wrap gap-1 ${props.class ?? ""}`}>
|
|
32
|
+
<For each={props.options}>
|
|
33
|
+
{(option) => (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
aria-pressed={selected(option.value)}
|
|
37
|
+
disabled={props.disabled || option.disabled}
|
|
38
|
+
title={option.disabledNote}
|
|
39
|
+
onClick={() => toggle(option)}
|
|
40
|
+
class={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${props.itemClass ?? ""}`}
|
|
41
|
+
classList={{
|
|
42
|
+
"border-[var(--ks-info,#38bdf8)] bg-[var(--ks-info,#38bdf8)] text-[var(--ks-info-fg,#7dd3fc)]": selected(option.value),
|
|
43
|
+
"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
|
+
"cursor-not-allowed opacity-50": !!props.disabled || !!option.disabled,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{option.label}
|
|
48
|
+
</button>
|
|
49
|
+
)}
|
|
50
|
+
</For>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -48,7 +48,8 @@ export interface FormActionsProps {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
export default function FormActions(props: FormActionsProps): JSX.Element {
|
|
51
|
-
const
|
|
51
|
+
const submitType = props.submitType ?? "button";
|
|
52
|
+
const submitHandler = submitType === "submit" ? undefined : props.onSubmit;
|
|
52
53
|
return (
|
|
53
54
|
<div
|
|
54
55
|
class={
|
|
@@ -65,8 +66,8 @@ export default function FormActions(props: FormActionsProps): JSX.Element {
|
|
|
65
66
|
{props.cancelLabel ?? "Cancel"}
|
|
66
67
|
</Button>
|
|
67
68
|
<Button
|
|
68
|
-
type={
|
|
69
|
-
onClick={
|
|
69
|
+
type={submitType}
|
|
70
|
+
onClick={submitHandler}
|
|
70
71
|
disabled={(props.submitting ?? false) || (props.submitDisabled ?? false)}
|
|
71
72
|
intent={props.danger ? "danger" : "primary"}
|
|
72
73
|
class="gap-2 w-full sm:w-auto"
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,7 @@ export { default as ChartLegend } from "./components/base/ChartLegend";
|
|
|
60
60
|
// single canonical package.
|
|
61
61
|
export { default as StatusPill, type StatusTone } from "./components/base/StatusPill";
|
|
62
62
|
export { default as SegmentedFilter, type SegmentedFilterOption } from "./components/base/SegmentedFilter";
|
|
63
|
+
export { default as MultiSelectGroup, type MultiSelectGroupProps, type MultiSelectOption } from "./components/base/MultiSelectGroup";
|
|
63
64
|
export { default as CopyButton } from "./components/base/CopyButton";
|
|
64
65
|
export { default as KpiCard, type KpiCardProps, type KpiTone } from "./components/base/KpiCard";
|
|
65
66
|
export { default as RadioCardGroup } from "./components/base/RadioCardGroup";
|