@kahitsan/ksui 0.37.0 → 0.38.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.37.0",
3
+ "version": "0.38.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",
@@ -3,6 +3,8 @@
3
3
 
4
4
  import { createSignal, onCleanup, onMount, Show } from "solid-js";
5
5
  import { Portal } from "solid-js/web";
6
+ import { useTopLayer } from "../../utils/top-layer";
7
+ import { usePopoverMount } from "../../utils/modal-layer";
6
8
  import Plus from "lucide-solid/icons/plus";
7
9
  import Camera from "lucide-solid/icons/camera";
8
10
  import FileIcon from "lucide-solid/icons/file";
@@ -14,6 +16,7 @@ interface Props {
14
16
  }
15
17
 
16
18
  export default function AddAttachmentTile(props: Props) {
19
+ const popoverMount = usePopoverMount();
17
20
  const [open, setOpen] = createSignal(false);
18
21
  const [pos, setPos] = createSignal({ top: 0, left: 0 });
19
22
  let btn: HTMLButtonElement | undefined;
@@ -60,9 +63,12 @@ export default function AddAttachmentTile(props: Props) {
60
63
  <span class="text-[10px] uppercase tracking-wider">{props.uploading ? "Uploading" : "Add"}</span>
61
64
  </button>
62
65
  <Show when={open()}>
63
- <Portal>
66
+ <Portal mount={popoverMount()}>
64
67
  <div
65
- ref={menu}
68
+ ref={(el) => {
69
+ menu = el;
70
+ onCleanup(useTopLayer(el));
71
+ }}
66
72
  style={{ top: `${pos().top}px`, left: `${pos().left}px` }}
67
73
  class="fixed z-[60] min-w-[160px] rounded-lg border border-[var(--ks-border-strong,#3f3f46)] bg-[var(--ks-surface-raised,#1a1a1a)] shadow-2xl p-1 ks-hud-clip-top-left-bottom-right"
68
74
  >
@@ -1,5 +1,7 @@
1
1
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2
2
  import { Portal } from "solid-js/web";
3
+ import { useTopLayer } from "../../utils/top-layer";
4
+ import { usePopoverMount } from "../../utils/modal-layer";
3
5
 
4
6
  export interface BadgeSelectOption {
5
7
  /** Stable identity of the option (what `value` matches and `onChange` emits). */
@@ -54,6 +56,7 @@ const DEFAULT_SEARCH_THRESHOLD = 5;
54
56
  // not a form-control picker. Kept separate so neither component grows a
55
57
  // trigger-shape/styling switch; do not merge them.
56
58
  export default function BadgeSelect(props: BadgeSelectProps): JSX.Element {
59
+ const popoverMount = usePopoverMount();
57
60
  const [open, setOpen] = createSignal(false);
58
61
  const [busy, setBusy] = createSignal(false);
59
62
  const [query, setQuery] = createSignal("");
@@ -172,9 +175,12 @@ export default function BadgeSelect(props: BadgeSelectProps): JSX.Element {
172
175
  {busy() ? "…" : currentLabel()}
173
176
  </button>
174
177
  <Show when={open()}>
175
- <Portal>
178
+ <Portal mount={popoverMount()}>
176
179
  <div
177
- ref={popupRef}
180
+ ref={(el) => {
181
+ popupRef = el;
182
+ onCleanup(useTopLayer(el));
183
+ }}
178
184
  role="listbox"
179
185
  class="z-[100] rounded-md border border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
180
186
  style={popupStyle()}
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { render, screen, fireEvent } from "@solidjs/testing-library";
3
3
  import { createSignal } from "solid-js";
4
- import DatePicker from "./DatePicker";
4
+ import DatePicker, { type DateRangeValue } from "./DatePicker";
5
5
 
6
6
  // DatePicker is the shared calendar popover used by every plugin's date input
7
7
  // and by DataTable's date filter. The key behaviors: renders a trigger button
@@ -48,4 +48,61 @@ describe("DatePicker", () => {
48
48
  const trigger = screen.getByText("Pick date").closest("button")!;
49
49
  expect(trigger.disabled).toBe(true);
50
50
  });
51
+
52
+ // Regression: a completed selection must close the popover, or the
53
+ // top-layer-promoted panel keeps intercepting clicks meant for whatever's
54
+ // underneath (e.g. a modal's submit button) after the user is done with it.
55
+ it("closes the popover and refocuses the trigger after a day is clicked", async () => {
56
+ const onChange = vi.fn();
57
+ render(() => <DatePicker value="2026-06-15" onChange={onChange} />);
58
+ const trigger = screen.getByText("Jun 15").closest("button")!;
59
+ await fireEvent.click(trigger);
60
+ expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
61
+
62
+ const day20 = screen.getByText("20", { exact: true });
63
+ await fireEvent.click(day20);
64
+
65
+ expect(onChange).toHaveBeenCalledWith("2026-06-20");
66
+ expect(screen.queryByTestId("datepicker-popover")).toBeNull();
67
+ expect(document.activeElement).toBe(trigger);
68
+ });
69
+
70
+ it("closes the popover after a quick-pick button is clicked", async () => {
71
+ const onChange = vi.fn();
72
+ render(() => <DatePicker value={null} onChange={onChange} />);
73
+ await fireEvent.click(screen.getByText("Pick date"));
74
+ expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
75
+
76
+ await fireEvent.click(screen.getByText("Yesterday"));
77
+
78
+ expect(onChange).toHaveBeenCalledOnce();
79
+ expect(screen.queryByTestId("datepicker-popover")).toBeNull();
80
+ });
81
+
82
+ it("keeps the popover open after the first pick in active range mode", async () => {
83
+ const [range, setRange] = createSignal<DateRangeValue>({ start: null, end: null });
84
+ render(() => <DatePicker range value={range()} onChange={setRange} />);
85
+ await fireEvent.click(screen.getByText("Pick date"));
86
+ // Range mode needs two clicks (start, then end); turning the toggle on
87
+ // is what puts the calendar into that two-click mode.
88
+ await fireEvent.click(screen.getByTestId("datepicker-end-date-toggle"));
89
+
90
+ const day20 = screen.getByText("20", { exact: true });
91
+ await fireEvent.click(day20);
92
+
93
+ // Only one bound is set so far — the popover must stay open for the second pick.
94
+ expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
95
+ });
96
+
97
+ it("closes the popover after a range quick-pick button (atomic, both bounds set)", async () => {
98
+ const [range, setRange] = createSignal<DateRangeValue>({ start: null, end: null });
99
+ render(() => <DatePicker range value={range()} onChange={setRange} />);
100
+ await fireEvent.click(screen.getByText("Pick date"));
101
+ expect(screen.getByTestId("datepicker-popover")).toBeTruthy();
102
+
103
+ await fireEvent.click(screen.getByText("Yesterday"));
104
+
105
+ expect(range().start).toBe(range().end);
106
+ expect(screen.queryByTestId("datepicker-popover")).toBeNull();
107
+ });
51
108
  });
@@ -43,6 +43,8 @@ import {
43
43
  type ParsedDate,
44
44
  } from "../../utils/parse-date";
45
45
  import { injectCSS } from "../../utils/inject-css";
46
+ import { useTopLayer } from "../../utils/top-layer";
47
+ import { usePopoverMount } from "../../utils/modal-layer";
46
48
 
47
49
  // ---------------------------------------------------------------------------
48
50
  // Injected CSS
@@ -249,6 +251,7 @@ const QUICK_OPTIONS_RANGE: RangeQuickOption[] = [
249
251
 
250
252
  export default function DatePicker(props: DatePickerProps) {
251
253
  ensureDatePickerStyle();
254
+ const popoverMount = usePopoverMount();
252
255
 
253
256
  const isRange = (): boolean => (props as DatePickerProps).range === true;
254
257
 
@@ -378,6 +381,9 @@ export default function DatePicker(props: DatePickerProps) {
378
381
  emitSingle(dateStr);
379
382
  setInputValue(formatDateEditable(dateStr));
380
383
  setPreview(null);
384
+ // withTime needs the popover to stay open so the user can still edit the
385
+ // time field below; otherwise the day pick IS the whole selection.
386
+ if (!props.withTime) closePicker();
381
387
  }
382
388
 
383
389
  function selectDateRange(dateStr: string) {
@@ -416,6 +422,10 @@ export default function DatePicker(props: DatePickerProps) {
416
422
  setInputValue(nextStart ? formatDateEditable(nextStart) : "");
417
423
  setEndInputValue(nextEnd ? formatDateEditable(nextEnd) : "");
418
424
  setPreview(null);
425
+ // Deliberately does NOT close: range mode picks two dates across two
426
+ // clicks (start, then end), so the popover must stay open between them.
427
+ // Closing here would strand the user after the first click with no way
428
+ // to pick the second bound.
419
429
  }
420
430
 
421
431
  /**
@@ -428,6 +438,10 @@ export default function DatePicker(props: DatePickerProps) {
428
438
  emitRange({ start: dateStr, end: dateStr });
429
439
  setInputValue(formatDateEditable(dateStr));
430
440
  setPreview(null);
441
+ // The End-date toggle is off, so this click IS a completed single-day
442
+ // pick (no withTime row exists in range mode — see the `!isRange()` guard
443
+ // on the time section below), unlike selectDateSingle's withTime check.
444
+ closePicker();
431
445
  }
432
446
 
433
447
  function selectDate(dateStr: string) {
@@ -444,6 +458,9 @@ export default function DatePicker(props: DatePickerProps) {
444
458
  setInputValue(range.start ? formatDateEditable(range.start) : "");
445
459
  setEndInputValue(range.end ? formatDateEditable(range.end) : "");
446
460
  setPreview(null);
461
+ // A quick-range preset sets both bounds atomically in one click, unlike a
462
+ // day-cell pick in selectDateRange — there's no second date left to pick.
463
+ closePicker();
447
464
  }
448
465
 
449
466
  function clear(e?: MouseEvent) {
@@ -516,6 +533,18 @@ export default function DatePicker(props: DatePickerProps) {
516
533
  });
517
534
  }
518
535
 
536
+ /**
537
+ * Closes the popover after a completed selection and returns focus to the
538
+ * trigger. Distinct from the outside-click/Escape close paths (which stay
539
+ * untouched): those close in response to the user looking elsewhere, so
540
+ * stealing focus back to the trigger there would fight whatever they just
541
+ * clicked (e.g. a modal's submit button) — the exact bug this fixes.
542
+ */
543
+ function closePicker() {
544
+ setOpen(false);
545
+ triggerRef?.focus();
546
+ }
547
+
519
548
  // ── Text input handling ────────────────────────────────────────────────────
520
549
 
521
550
  function handleInput(e: InputEvent, field: "start" | "end") {
@@ -712,12 +741,16 @@ export default function DatePicker(props: DatePickerProps) {
712
741
  </button>
713
742
  </Show>
714
743
 
715
- {/* Popover — portaled to document.body so ancestor clip-path / overflow
744
+ {/* Popover — portaled to document.body (or the ancestor dialog's
745
+ element, see modal-layer.ts) so ancestor clip-path / overflow
716
746
  (sheet modals use clip-path corners) doesn't clip the calendar. */}
717
747
  <Show when={open()}>
718
- <Portal>
748
+ <Portal mount={popoverMount()}>
719
749
  <div
720
- ref={popoverPanelRef}
750
+ ref={(el) => {
751
+ popoverPanelRef = el;
752
+ onCleanup(useTopLayer(el));
753
+ }}
721
754
  data-testid="datepicker-popover"
722
755
  class="ksui-datepicker-popover"
723
756
  style={{
@@ -18,6 +18,7 @@
18
18
  import { JSX, onCleanup, onMount, splitProps } from "solid-js";
19
19
  import { autoFocusOnMount, lockPullToRefresh, unlockPullToRefresh, useFocusTrap } from "../../utils/dom";
20
20
  import { injectCSS } from "../../utils/inject-css";
21
+ import { ModalLayerProvider } from "../../utils/modal-layer";
21
22
 
22
23
  export type ModalSize = "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "5xl" | "7xl";
23
24
  export type ModalTone = "default" | "danger";
@@ -145,7 +146,10 @@ function DialogModal(local: LocalProps): JSX.Element {
145
146
  class={`ksui-modal-card${local.tone === "danger" ? " danger" : ""}`}
146
147
  style={{ "max-width": SIZE_MAX_WIDTH[local.size ?? "lg"] }}
147
148
  >
148
- {local.children}
149
+ {/* Value is a closure over dialogEl, not its value at Provider-creation
150
+ time — dialogEl is still undefined here (ref hasn't fired yet), but
151
+ every popover reads this accessor lazily, well after mount. */}
152
+ <ModalLayerProvider value={() => dialogEl ?? null}>{local.children}</ModalLayerProvider>
149
153
  </div>
150
154
  </dialog>
151
155
  );
@@ -170,6 +174,10 @@ function SheetModal(local: LocalProps, size?: ModalSize): JSX.Element {
170
174
  local.onClose();
171
175
  };
172
176
 
177
+ // No ModalLayerProvider here: a sheet is a plain <div>, never showModal()'d,
178
+ // so it introduces no inertness barrier of its own — children keep whatever
179
+ // ModalLayerContext value is already ambient (an outer DialogModal's
180
+ // dialogEl if nested inside one, otherwise the default null/document.body).
173
181
  return (
174
182
  <div class="ksui-modal-sheet-overlay">
175
183
  {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
@@ -20,7 +20,9 @@
20
20
  // and `lockedIds` anchors specific chips.
21
21
 
22
22
  import { Portal } from "solid-js/web";
23
- import { createEffect, createSignal, For, onMount, Show, type JSX } from "solid-js";
23
+ import { createEffect, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js";
24
+ import { useTopLayer } from "../../utils/top-layer";
25
+ import { usePopoverMount } from "../../utils/modal-layer";
24
26
  import { highlightMatch } from "../../utils/highlight";
25
27
  import UserPlus from "lucide-solid/icons/user-plus";
26
28
  import Search from "lucide-solid/icons/search";
@@ -108,6 +110,7 @@ export default function ComboBox<T>(props: ComboBoxProps<T>): JSX.Element {
108
110
  // Single-select — button trigger + popup with its own search input.
109
111
  // ---------------------------------------------------------------------------
110
112
  function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
113
+ const popoverMount = usePopoverMount();
111
114
  let triggerRef: HTMLButtonElement | undefined;
112
115
  let popupRef: HTMLDivElement | undefined;
113
116
  let inputRef: HTMLInputElement | undefined;
@@ -201,9 +204,12 @@ function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
201
204
  </button>
202
205
 
203
206
  <Show when={eng.open()}>
204
- <Portal>
207
+ <Portal mount={popoverMount()}>
205
208
  <div
206
- ref={popupRef}
209
+ ref={(el) => {
210
+ popupRef = el;
211
+ onCleanup(useTopLayer(el));
212
+ }}
207
213
  data-testid={tid("popup")}
208
214
  class="z-[10000] rounded-md border border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
209
215
  style={eng.popupStyle()}
@@ -316,6 +322,7 @@ function SingleComboBox<T>(props: ComboBoxSingleProps<T>): JSX.Element {
316
322
  type DisplayOption<T> = { create: true; name: string } | { create: false; item: T };
317
323
 
318
324
  function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
325
+ const popoverMount = usePopoverMount();
319
326
  let wrapperRef: HTMLDivElement | undefined;
320
327
  let popupRef: HTMLDivElement | undefined;
321
328
  let inputRef: HTMLInputElement | undefined;
@@ -545,9 +552,12 @@ function MultiComboBox<T>(props: ComboBoxMultiProps<T>): JSX.Element {
545
552
  </div>
546
553
 
547
554
  <Show when={eng.open() && !props.disabled}>
548
- <Portal>
555
+ <Portal mount={popoverMount()}>
549
556
  <div
550
- ref={popupRef}
557
+ ref={(el) => {
558
+ popupRef = el;
559
+ onCleanup(useTopLayer(el));
560
+ }}
551
561
  data-testid={tid("popup")}
552
562
  role="listbox"
553
563
  aria-label={`${props.noun} search results`}
@@ -7,6 +7,8 @@
7
7
  // still works as a plain notes editor.
8
8
 
9
9
  import { Portal } from "solid-js/web";
10
+ import { useTopLayer } from "../../utils/top-layer";
11
+ import { usePopoverMount } from "../../utils/modal-layer";
10
12
  import { createEffect, createSignal, createUniqueId, For, onCleanup, onMount, Show, type JSX } from "solid-js";
11
13
  import UserRound from "lucide-solid/icons/user-round";
12
14
  import Loader2 from "lucide-solid/icons/loader-2";
@@ -122,6 +124,7 @@ function findTrigger(
122
124
  }
123
125
 
124
126
  export default function MentionTextarea(props: MentionTextareaProps): JSX.Element {
127
+ const popoverMount = usePopoverMount();
125
128
  const listboxId = createUniqueId();
126
129
  const optionId = (clientId: number) => `${listboxId}-option-${clientId}`;
127
130
  const [open, setOpen] = createSignal(false);
@@ -426,9 +429,12 @@ export default function MentionTextarea(props: MentionTextareaProps): JSX.Elemen
426
429
  </div>
427
430
 
428
431
  <Show when={open()}>
429
- <Portal>
432
+ <Portal mount={popoverMount()}>
430
433
  <div
431
- ref={popupRef}
434
+ ref={(el) => {
435
+ popupRef = el;
436
+ onCleanup(useTopLayer(el));
437
+ }}
432
438
  data-testid="mention-popup"
433
439
  class="z-[120] rounded-md border border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
434
440
  style={popupStyle()}
@@ -1,4 +1,6 @@
1
1
  import { Portal } from "solid-js/web";
2
+ import { useTopLayer } from "../../utils/top-layer";
3
+ import { usePopoverMount } from "../../utils/modal-layer";
2
4
  import {
3
5
  createEffect,
4
6
  createMemo,
@@ -65,6 +67,7 @@ function groupAndSort(accounts: PaymentAccountOption[]): Array<[string, PaymentA
65
67
  }
66
68
 
67
69
  export default function PaymentAccountPicker(props: PaymentAccountPickerProps): JSX.Element {
70
+ const popoverMount = usePopoverMount();
68
71
  const [open, setOpen] = createSignal(false);
69
72
  const [accounts, setAccounts] = createSignal<PaymentAccountOption[]>([]);
70
73
  const [loading, setLoading] = createSignal(true);
@@ -238,9 +241,12 @@ export default function PaymentAccountPicker(props: PaymentAccountPickerProps):
238
241
  </button>
239
242
 
240
243
  <Show when={open()}>
241
- <Portal>
244
+ <Portal mount={popoverMount()}>
242
245
  <div
243
- ref={popupRef}
246
+ ref={(el) => {
247
+ popupRef = el;
248
+ onCleanup(useTopLayer(el));
249
+ }}
244
250
  data-testid="payment-account-picker-popup"
245
251
  class="z-[100] rounded-md border border-[var(--ks-border-strong,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-surface-raised,#1a1a1a)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
246
252
  style={popupStyle()}
@@ -1,5 +1,7 @@
1
1
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
2
2
  import { Portal } from "solid-js/web";
3
+ import { useTopLayer } from "../../utils/top-layer";
4
+ import { usePopoverMount } from "../../utils/modal-layer";
3
5
  import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down";
4
6
  import X from "lucide-solid/icons/x";
5
7
 
@@ -38,6 +40,7 @@ const POPUP_FLIP_THRESHOLD = 200;
38
40
  // with `overflow: hidden` (e.g. the rounded table cards) and flip upward when
39
41
  // there's not enough room below the trigger.
40
42
  export default function SearchableSelect(props: SearchableSelectProps): JSX.Element {
43
+ const popoverMount = usePopoverMount();
41
44
  const [open, setOpen] = createSignal(false);
42
45
  const [query, setQuery] = createSignal("");
43
46
  const [busy, setBusy] = createSignal(false);
@@ -166,9 +169,12 @@ export default function SearchableSelect(props: SearchableSelectProps): JSX.Elem
166
169
  <ChevronsUpDown size={12} class="text-[var(--ks-fg-subtle,#71717a)] shrink-0" />
167
170
  </button>
168
171
  <Show when={open()}>
169
- <Portal>
172
+ <Portal mount={popoverMount()}>
170
173
  <div
171
- ref={popupRef}
174
+ ref={(el) => {
175
+ popupRef = el;
176
+ onCleanup(useTopLayer(el));
177
+ }}
172
178
  class="z-[10000] rounded-md border border-[var(--ks-input-border,#3f3f46)] bg-[color-mix(in_srgb,var(--ks-overlay-surface,#18181b)_95%,transparent)] backdrop-blur shadow-xl overflow-hidden flex flex-col"
173
179
  style={popupStyle()}
174
180
  >