@kahitsan/ksui 0.41.1 → 0.43.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.41.1",
3
+ "version": "0.43.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,28 @@ describe("DatePicker", () => {
43
43
  expect(onChange).toHaveBeenCalledWith("2026-06-20");
44
44
  });
45
45
 
46
+ it("labels the trigger and calendar navigation", async () => {
47
+ render(() => <DatePicker ariaLabel="Paid date" value="2026-06-15" onChange={() => {}} />);
48
+ await fireEvent.click(screen.getByRole("button", { name: "Paid date" }));
49
+ expect(screen.getByRole("button", { name: /Previous month from/ })).toBeTruthy();
50
+ expect(screen.getByRole("button", { name: /Next month from/ })).toBeTruthy();
51
+ });
52
+ it("keeps the trigger and clear action keyboard reachable", () => {
53
+ render(() => <DatePicker value="2026-06-15" onChange={() => {}} />);
54
+ const trigger = screen.getByText("Jun 15").closest("button")!;
55
+ expect(trigger.tabIndex).toBe(0);
56
+ expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
57
+ const clear = screen.getByRole("button", { name: "Clear date" });
58
+ expect(clear.tabIndex).toBe(0);
59
+ });
60
+ it("opens from native keyboard activation", async () => {
61
+ render(() => <DatePicker value={null} onChange={() => {}} />);
62
+ const trigger = screen.getByText("Pick date").closest("button")!;
63
+ trigger.focus();
64
+ await fireEvent.keyDown(trigger, { key: "Enter" });
65
+ await fireEvent.click(trigger);
66
+ expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
67
+ });
46
68
  it("renders disabled trigger when disabled prop is true", () => {
47
69
  render(() => <DatePicker value={null} onChange={() => {}} disabled />);
48
70
  const trigger = screen.getByText("Pick date").closest("button")!;
@@ -165,6 +165,8 @@ interface DatePickerSharedProps {
165
165
  placeholder?: string;
166
166
  withTime?: boolean;
167
167
  disabled?: boolean;
168
+ /** Accessible purpose label for the trigger, for example "Paid date". */
169
+ ariaLabel?: string;
168
170
  /** Override the trigger button class entirely (escape hatch for custom triggers). */
169
171
  triggerClass?: string;
170
172
  }
@@ -724,7 +726,9 @@ export default function DatePicker(props: DatePickerProps) {
724
726
  ref={triggerRef}
725
727
  onClick={openPicker}
726
728
  type="button"
727
- tabIndex={-1}
729
+ aria-label={props.ariaLabel}
730
+ aria-haspopup="dialog"
731
+ aria-expanded={open()}
728
732
  disabled={props.disabled}
729
733
  class={
730
734
  props.triggerClass ??
@@ -736,7 +740,7 @@ export default function DatePicker(props: DatePickerProps) {
736
740
  </button>
737
741
  {/* Clear button */}
738
742
  <Show when={hasValue()}>
739
- <button type="button" tabIndex={-1} class="ksui-datepicker-clear-btn" onClick={clear}>
743
+ <button type="button" aria-label="Clear date" class="ksui-datepicker-clear-btn" onClick={clear}>
740
744
  <X size={14} />
741
745
  </button>
742
746
  </Show>
@@ -868,11 +872,11 @@ export default function DatePicker(props: DatePickerProps) {
868
872
  <div class="ksui-datepicker-section">
869
873
  {/* Month nav */}
870
874
  <div class="ksui-datepicker-nav">
871
- <button type="button" onClick={prevMonth} class="ksui-datepicker-nav-btn">
875
+ <button type="button" aria-label={`Previous month from ${monthLabel()}`} onClick={prevMonth} class="ksui-datepicker-nav-btn">
872
876
  <ChevronLeft size={16} />
873
877
  </button>
874
878
  <span class="ksui-datepicker-month-label">{monthLabel()}</span>
875
- <button type="button" onClick={nextMonth} class="ksui-datepicker-nav-btn">
879
+ <button type="button" aria-label={`Next month from ${monthLabel()}`} onClick={nextMonth} class="ksui-datepicker-nav-btn">
876
880
  <ChevronRight size={16} />
877
881
  </button>
878
882
  </div>
@@ -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
+ }
@@ -27,6 +27,12 @@ describe("SegmentedFilter", () => {
27
27
  expect(radios[0].getAttribute("aria-checked")).toBe("true");
28
28
  });
29
29
 
30
+ it("supports a mobile-safe scrolling row", () => {
31
+ const { container } = render(() => (
32
+ <SegmentedFilter options={STRING_OPTIONS} value="today" onChange={() => {}} scrollable />
33
+ ));
34
+ expect(container.querySelector('[role="radiogroup"]')?.className).toContain("overflow-x-auto");
35
+ });
30
36
  it("renders object options with an explicit label", () => {
31
37
  const { getAllByRole } = render(() => (
32
38
  <SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={() => {}} />
@@ -23,6 +23,8 @@ interface SegmentedFilterProps {
23
23
  class?: string;
24
24
  /** Accessible label for the radiogroup wrapper (WAI-ARIA radiogroup pattern). */
25
25
  ariaLabel?: string;
26
+ /** Keep long rows reachable on narrow screens with horizontal scrolling. */
27
+ scrollable?: boolean;
26
28
  }
27
29
 
28
30
  // A rounded bordered row of segment buttons with one active at a time.
@@ -85,7 +87,7 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
85
87
  <div
86
88
  role="radiogroup"
87
89
  aria-label={props.ariaLabel}
88
- class={`flex rounded-lg border border-[var(--ks-border,rgba(39,39,42,0.5))] overflow-hidden ${props.class ?? ""}`}
90
+ class={`flex rounded-lg border border-[var(--ks-border,rgba(39,39,42,0.5))] ${props.scrollable ? "max-w-full overflow-x-auto" : "overflow-hidden"} ${props.class ?? ""}`}
89
91
  onKeyDown={onKeyDown}
90
92
  >
91
93
  <For each={props.options}>
@@ -115,7 +117,7 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
115
117
  if (opt.disabled) return;
116
118
  props.onChange(opt.value);
117
119
  }}
118
- class="px-3 py-1.5 text-xs transition-colors"
120
+ class="shrink-0 px-3 py-1.5 text-xs transition-colors"
119
121
  classList={{
120
122
  capitalize: opt.capitalize,
121
123
  "cursor-not-allowed": opt.disabled,
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";