@kahitsan/ksui 0.35.0 → 0.36.1

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.35.0",
3
+ "version": "0.36.1",
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",
@@ -32,6 +32,10 @@ const styles: Record<string, string> = {
32
32
  "animate-shimmer": "ks-progress-shimmer",
33
33
  };
34
34
 
35
+ // ProgressColor is derived from COLOR_MAP so adding a hue later flows here
36
+ // without re-syncing a hardcoded list.
37
+ export type ProgressColor = keyof typeof COLOR_MAP;
38
+
35
39
  export interface ProgressBarProps extends JSX.HTMLAttributes<HTMLDivElement> {
36
40
  progress: number;
37
41
  icon?: Component<{ size: number; class?: string }>;
@@ -45,6 +49,9 @@ export interface ProgressBarProps extends JSX.HTMLAttributes<HTMLDivElement> {
45
49
  // LiveTimer push the live countdown into the right slot while the
46
50
  // total label sits on the left.
47
51
  rightLabel?: string;
52
+ // Explicit color signal — class-substring sniffing broke when COLOR_AMBER
53
+ // was tokenized (4f6ed40 dropped the literal "amber" from the class).
54
+ color?: ProgressColor;
48
55
  class?: string;
49
56
  }
50
57
 
@@ -163,6 +170,7 @@ const ProgressBar: Component<ProgressBarProps> = (props) => {
163
170
  "position",
164
171
  "hidePercentage",
165
172
  "rightLabel",
173
+ "color",
166
174
  "class",
167
175
  ]);
168
176
 
@@ -184,7 +192,12 @@ const ProgressBar: Component<ProgressBarProps> = (props) => {
184
192
  return { progress: Math.max(0, Math.min(100, raw)), overflow: Math.max(0, raw - 100) };
185
193
  });
186
194
 
187
- const colorInfo = createMemo(() => extractColorInfo(classProp() ?? ""));
195
+ const colorInfo = createMemo(() => {
196
+ // Explicit color wins; fall back to class-substring sniffing for back-compat
197
+ // with every caller that still drives color via a "text-red-400"-style class.
198
+ if (local.color) return COLOR_MAP[local.color];
199
+ return extractColorInfo(classProp() ?? "");
200
+ });
188
201
  const iconSize = createMemo(() => extractTextSize(classProp() ?? ""));
189
202
 
190
203
  const containerClasses = createMemo(() =>
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@solidjs/testing-library";
3
+ import SegmentedFilter, { type SegmentedFilterOption } from "./SegmentedFilter";
4
+
5
+ // Back-compat: bare-string options are still accepted alongside the object
6
+ // form, so callers frozen on the old shape don't need a migration.
7
+ const STRING_OPTIONS: SegmentedFilterOption[] = ["today", "week", "month"];
8
+
9
+ const OBJECT_OPTIONS: SegmentedFilterOption[] = [
10
+ { value: "table", label: "Table" },
11
+ { value: "calendar", label: "Calendar" },
12
+ ];
13
+
14
+ const WITH_DISABLED: SegmentedFilterOption[] = [
15
+ { value: "daily", label: "Per day" },
16
+ { value: "hourly", label: "Per hour", disabled: true, disabledNote: "Unavailable for this staff type" },
17
+ { value: "fixed", label: "Fixed period" },
18
+ ];
19
+
20
+ describe("SegmentedFilter", () => {
21
+ it("renders bare-string options capitalized (back-compat)", () => {
22
+ const { getAllByRole } = render(() => (
23
+ <SegmentedFilter options={STRING_OPTIONS} value="today" onChange={() => {}} />
24
+ ));
25
+ const radios = getAllByRole("radio");
26
+ expect(radios.map((r) => r.textContent)).toEqual(["today", "week", "month"]);
27
+ expect(radios[0].getAttribute("aria-checked")).toBe("true");
28
+ });
29
+
30
+ it("renders object options with an explicit label", () => {
31
+ const { getAllByRole } = render(() => (
32
+ <SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={() => {}} />
33
+ ));
34
+ const radios = getAllByRole("radio");
35
+ expect(radios.map((r) => r.textContent)).toEqual(["Table", "Calendar"]);
36
+ });
37
+
38
+ it("emits the clicked value on an enabled segment", () => {
39
+ const onChange = vi.fn();
40
+ const { getAllByRole } = render(() => (
41
+ <SegmentedFilter options={OBJECT_OPTIONS} value="table" onChange={onChange} />
42
+ ));
43
+ fireEvent.click(getAllByRole("radio")[1]);
44
+ expect(onChange).toHaveBeenCalledWith("calendar");
45
+ });
46
+
47
+ it("no-ops a click on a disabled segment", () => {
48
+ const onChange = vi.fn();
49
+ const { getAllByRole } = render(() => (
50
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
51
+ ));
52
+ fireEvent.click(getAllByRole("radio")[1]);
53
+ expect(onChange).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("exposes aria-disabled and the disabledNote via title + sr-only text", () => {
57
+ const { getAllByRole, getByText } = render(() => (
58
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
59
+ ));
60
+ const disabledRadio = getAllByRole("radio")[1];
61
+ expect(disabledRadio.getAttribute("aria-disabled")).toBe("true");
62
+ expect(disabledRadio.getAttribute("title")).toBe("Unavailable for this staff type");
63
+ expect(getByText("Unavailable for this staff type").className).toContain("sr-only");
64
+ });
65
+
66
+ it("does not set aria-disabled on enabled segments", () => {
67
+ const { getAllByRole } = render(() => (
68
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
69
+ ));
70
+ expect(getAllByRole("radio")[0].getAttribute("aria-disabled")).toBe("false");
71
+ });
72
+
73
+ it("pulls disabled segments out of tab order", () => {
74
+ const { getAllByRole } = render(() => (
75
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={() => {}} />
76
+ ));
77
+ expect(getAllByRole("radio")[1].getAttribute("tabindex")).toBe("-1");
78
+ });
79
+
80
+ it("ArrowRight skips a disabled segment and selects the next enabled one", () => {
81
+ const onChange = vi.fn();
82
+ const { container } = render(() => (
83
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
84
+ ));
85
+ fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "ArrowRight" });
86
+ expect(onChange).toHaveBeenCalledWith("fixed");
87
+ });
88
+
89
+ it("ArrowLeft wraps past a disabled segment to the previous enabled one", () => {
90
+ const onChange = vi.fn();
91
+ const { container } = render(() => (
92
+ <SegmentedFilter options={WITH_DISABLED} value="daily" onChange={onChange} />
93
+ ));
94
+ fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "ArrowLeft" });
95
+ expect(onChange).toHaveBeenCalledWith("fixed");
96
+ });
97
+
98
+ it("End lands on the last enabled segment even if the last option is disabled", () => {
99
+ const onChange = vi.fn();
100
+ const trailingDisabled: SegmentedFilterOption[] = [
101
+ { value: "a", label: "A" },
102
+ { value: "b", label: "B" },
103
+ { value: "c", label: "C", disabled: true },
104
+ ];
105
+ const { container } = render(() => (
106
+ <SegmentedFilter options={trailingDisabled} value="a" onChange={onChange} />
107
+ ));
108
+ fireEvent.keyDown(container.querySelector('[role="radiogroup"]')!, { key: "End" });
109
+ expect(onChange).toHaveBeenCalledWith("b");
110
+ });
111
+ });
@@ -2,8 +2,13 @@ import { For, type JSX } from "solid-js";
2
2
 
3
3
  /** One choice in the segmented row. A bare string uses the value as the label
4
4
  * and is rendered capitalized; an object lets the caller supply an explicit
5
- * label that is NOT capitalized (for buckets or other non-status toggles). */
6
- export type SegmentedFilterOption = string | { value: string; label: string };
5
+ * label that is NOT capitalized (for buckets or other non-status toggles).
6
+ * `disabled` mutes the segment and pulls it out of roving arrow-key nav;
7
+ * `disabledNote` (shown only when disabled) explains why via title + sr-only
8
+ * text, since a muted, unclickable control is otherwise unexplained. */
9
+ export type SegmentedFilterOption =
10
+ | string
11
+ | { value: string; label: string; disabled?: boolean; disabledNote?: string };
7
12
 
8
13
  interface SegmentedFilterProps {
9
14
  /** The available segments, left to right. */
@@ -29,19 +34,28 @@ interface SegmentedFilterProps {
29
34
  export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Element {
30
35
  const buttonRefs: (HTMLButtonElement | undefined)[] = [];
31
36
  const optionOf = (o: SegmentedFilterOption) =>
32
- typeof o === "string" ? { value: o, label: o, capitalize: true } : { ...o, capitalize: false };
37
+ typeof o === "string"
38
+ ? { value: o, label: o, capitalize: true, disabled: false, disabledNote: undefined as string | undefined }
39
+ : { disabled: false, disabledNote: undefined as string | undefined, ...o, capitalize: false };
33
40
 
34
41
  const currentIndex = () => {
35
42
  const i = props.options.findIndex((o) => optionOf(o).value === props.value);
36
43
  return i >= 0 ? i : 0;
37
44
  };
38
45
 
39
- const selectByIndex = (idx: number) => {
46
+ // Roving nav must skip disabled segments while preserving the requested
47
+ // direction (Home/End pass direction 1 since idx is already the boundary);
48
+ // a full lap finding no enabled option is the only way this doesn't move.
49
+ const selectByIndex = (idx: number, direction: 1 | -1 = 1) => {
40
50
  const list = props.options;
41
51
  if (list.length === 0) return;
42
- const wrapped = ((idx % list.length) + list.length) % list.length;
43
- props.onChange(optionOf(list[wrapped]).value);
44
- buttonRefs[wrapped]?.focus();
52
+ for (let step = 0, cursor = idx; step < list.length; step++, cursor += direction) {
53
+ const wrapped = ((cursor % list.length) + list.length) % list.length;
54
+ if (optionOf(list[wrapped]).disabled) continue;
55
+ props.onChange(optionOf(list[wrapped]).value);
56
+ buttonRefs[wrapped]?.focus();
57
+ return;
58
+ }
45
59
  };
46
60
 
47
61
  const onKeyDown = (e: KeyboardEvent) => {
@@ -49,20 +63,20 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
49
63
  case "ArrowRight":
50
64
  case "ArrowDown":
51
65
  e.preventDefault();
52
- selectByIndex(currentIndex() + 1);
66
+ selectByIndex(currentIndex() + 1, 1);
53
67
  break;
54
68
  case "ArrowLeft":
55
69
  case "ArrowUp":
56
70
  e.preventDefault();
57
- selectByIndex(currentIndex() - 1);
71
+ selectByIndex(currentIndex() - 1, -1);
58
72
  break;
59
73
  case "Home":
60
74
  e.preventDefault();
61
- selectByIndex(0);
75
+ selectByIndex(0, 1);
62
76
  break;
63
77
  case "End":
64
78
  e.preventDefault();
65
- selectByIndex(props.options.length - 1);
79
+ selectByIndex(props.options.length - 1, -1);
66
80
  break;
67
81
  }
68
82
  };
@@ -78,25 +92,46 @@ export default function SegmentedFilter(props: SegmentedFilterProps): JSX.Elemen
78
92
  {(o, i) => {
79
93
  const opt = optionOf(o);
80
94
  const selected = () => props.value === opt.value;
81
- const isTabStop = () => selected() || (!props.value && i() === 0);
95
+ // Default tab stop falls to the first enabled option when nothing
96
+ // is selected yet, so an all-disabled-first row is still reachable.
97
+ const firstEnabledIndex = () => {
98
+ const idx = props.options.findIndex((candidate) => !optionOf(candidate).disabled);
99
+ return idx >= 0 ? idx : 0;
100
+ };
101
+ const isTabStop = () => selected() || (!props.value && i() === firstEnabledIndex());
102
+ const noteId = `${props.testIdPrefix ?? "segmented"}-${opt.value}-note`;
82
103
  return (
83
104
  <button
84
105
  ref={(el) => (buttonRefs[i()] = el)}
85
106
  type="button"
86
107
  role="radio"
87
108
  aria-checked={selected()}
88
- tabIndex={isTabStop() ? 0 : -1}
109
+ aria-disabled={opt.disabled}
110
+ aria-describedby={opt.disabled && opt.disabledNote ? noteId : undefined}
111
+ title={opt.disabled ? opt.disabledNote : undefined}
112
+ tabIndex={opt.disabled ? -1 : isTabStop() ? 0 : -1}
89
113
  data-testid={props.testIdPrefix ? `${props.testIdPrefix}-${opt.value}` : undefined}
90
- onClick={() => props.onChange(opt.value)}
91
- class="px-3 py-1.5 text-xs transition-colors cursor-pointer"
114
+ onClick={() => {
115
+ if (opt.disabled) return;
116
+ props.onChange(opt.value);
117
+ }}
118
+ class="px-3 py-1.5 text-xs transition-colors"
92
119
  classList={{
93
120
  capitalize: opt.capitalize,
94
- "bg-[var(--ks-accent,#fbbf24)]/20 text-[var(--ks-accent,#fbbf24)]": selected(),
121
+ "cursor-not-allowed": opt.disabled,
122
+ "cursor-pointer": !opt.disabled,
123
+ "bg-[var(--ks-accent,#fbbf24)]/20 text-[var(--ks-accent,#fbbf24)]": selected() && !opt.disabled,
124
+ "text-[var(--ks-fg-subtle,#71717a)]": opt.disabled,
95
125
  "text-[var(--ks-fg-muted,#a1a1aa)] hover:text-[var(--ks-fg,#ffffff)] hover:bg-[var(--ks-surface-raised,#1a1a1a)]":
96
- !selected(),
126
+ !selected() && !opt.disabled,
97
127
  }}
98
128
  >
99
129
  {opt.label}
130
+ {opt.disabled && opt.disabledNote ? (
131
+ <span id={noteId} class="sr-only">
132
+ {opt.disabledNote}
133
+ </span>
134
+ ) : null}
100
135
  </button>
101
136
  );
102
137
  }}
@@ -11,7 +11,7 @@ import Play from "lucide-solid/icons/play";
11
11
  import AlertTriangle from "lucide-solid/icons/triangle-alert";
12
12
  import Check from "lucide-solid/icons/check";
13
13
  import Calendar from "lucide-solid/icons/calendar";
14
- import ProgressBar from "../base/ProgressBar";
14
+ import ProgressBar, { type ProgressColor } from "../base/ProgressBar";
15
15
 
16
16
  export interface LiveTimerProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "class"> {
17
17
  // Core timing
@@ -354,6 +354,29 @@ const LiveTimer: Component<LiveTimerProps> = (props) => {
354
354
  return staticConfig().colorClass;
355
355
  });
356
356
 
357
+ // Mirrors colorClass's scenario logic but returns a COLOR_MAP key for
358
+ // ProgressBar's `color` prop — the class strings above were tokenized
359
+ // (4f6ed40) so the fill's class-substring sniff can no longer recover
360
+ // the hue. COMPLETED had no color substring even pre-tokenization, so it
361
+ // relies on the explicit prop rather than ever reaching the back-compat path.
362
+ const colorName = createMemo<ProgressColor>(() => {
363
+ switch (scenario()) {
364
+ case SCENARIO_COUNTDOWN_TIMER: {
365
+ const p = progress();
366
+ return p <= 25 ? "green" : p <= 75 ? "amber" : "red";
367
+ }
368
+ case SCENARIO_COUNTDOWN_TO_START:
369
+ return "blue";
370
+ case SCENARIO_OPEN_TIMER:
371
+ return "green";
372
+ case SCENARIO_OVERDUE:
373
+ return "purple";
374
+ case SCENARIO_COMPLETED:
375
+ default:
376
+ return "slate";
377
+ }
378
+ });
379
+
357
380
  const finalClass = createMemo(() => {
358
381
  const user = local.class ?? "";
359
382
  if (user.includes("border-") && user.includes("text-")) return user;
@@ -410,6 +433,7 @@ const LiveTimer: Component<LiveTimerProps> = (props) => {
410
433
  position={staticConfig().position}
411
434
  hidePercentage={resolvedHidePercentage()}
412
435
  shimmer={staticConfig().shimmer}
436
+ color={colorName()}
413
437
  class={finalClass()}
414
438
  {...others}
415
439
  />
@@ -424,6 +448,7 @@ const LiveTimer: Component<LiveTimerProps> = (props) => {
424
448
  hidePercentage
425
449
  rightLabel={statusLabel()}
426
450
  shimmer={staticConfig().shimmer}
451
+ color={colorName()}
427
452
  class={finalClass()}
428
453
  {...others}
429
454
  />