@kahitsan/ksui 0.37.0 → 0.37.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.37.0",
3
+ "version": "0.37.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",
@@ -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
  >
@@ -8,6 +8,8 @@
8
8
  // vouchers through a peer proxy route instead (same response shape required).
9
9
 
10
10
  import { Portal } from "solid-js/web";
11
+ import { useTopLayer } from "../../utils/top-layer";
12
+ import { usePopoverMount } from "../../utils/modal-layer";
11
13
  import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js";
12
14
  import Ticket from "lucide-solid/icons/ticket";
13
15
  import X from "lucide-solid/icons/x";
@@ -98,6 +100,7 @@ function formatVoucherDescription(v: VoucherOption): string {
98
100
  }
99
101
 
100
102
  export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
103
+ const popoverMount = usePopoverMount();
101
104
  const [open, setOpen] = createSignal(false);
102
105
  const [vouchers, setVouchers] = createSignal<VoucherOption[]>([]);
103
106
  const [loading, setLoading] = createSignal(false);
@@ -264,9 +267,12 @@ export default function VoucherPicker(props: VoucherPickerProps): JSX.Element {
264
267
  </button>
265
268
 
266
269
  <Show when={open()}>
267
- <Portal>
270
+ <Portal mount={popoverMount()}>
268
271
  <div
269
- ref={popupRef}
272
+ ref={(el) => {
273
+ popupRef = el;
274
+ onCleanup(useTopLayer(el));
275
+ }}
270
276
  data-testid="voucher-picker-popup"
271
277
  class="z-[100] 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"
272
278
  style={popupStyle()}
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext, type Accessor } from "solid-js";
2
+
3
+ // A Portal-based popover (DatePicker, ComboBox, ...) must mount as a DOM
4
+ // DESCENDANT of an ancestor dialog-variant Modal's <dialog> element, not
5
+ // document.body. dialogEl.showModal() marks everything OUTSIDE the dialog's
6
+ // flat-tree subtree inert, and inertness is computed on the flat tree —
7
+ // independent of the Popover API's top-layer PAINT order (useTopLayer only
8
+ // fixes painting). A popover portaled straight to document.body from inside
9
+ // an open <dialog> paints above it but is excluded from hit-testing, so real
10
+ // clicks never reach it even though it's visibly on top.
11
+ //
12
+ // Default is `null`: no ancestor dialog (standalone usage), or the sheet
13
+ // variant (a plain <div>, no showModal(), so no inertness barrier exists) —
14
+ // both mean "mount to document.body", today's behavior.
15
+ const ModalLayerContext = createContext<Accessor<HTMLElement | null>>(() => null);
16
+
17
+ export const ModalLayerProvider = ModalLayerContext.Provider;
18
+
19
+ /**
20
+ * Mount target for a Portal-based popover: the nearest ancestor dialog's
21
+ * element when rendered inside a dialog-variant Modal, else document.body.
22
+ * Called once per popover-open (the Portal itself only reads `mount` at
23
+ * creation), so a plain `Node` accessor — not a reactive signal — is enough.
24
+ */
25
+ export function usePopoverMount(): () => Node {
26
+ const layer = useContext(ModalLayerContext);
27
+ return () => layer() ?? document.body;
28
+ }
@@ -0,0 +1,66 @@
1
+ import { onMount } from "solid-js";
2
+ import { injectCSS } from "./inject-css";
3
+
4
+ const STYLE_ID = "ksui-toplayer-style";
5
+ // UA is the lowest cascade priority, so any author rule (background, border,
6
+ // padding, width, sizing, ...) already beats [popover]'s UA defaults without
7
+ // help — resetting those was the bug (this stylesheet injects AFTER each
8
+ // component's own CSS, so a same-specificity reset would win by source order
9
+ // and strip the component's real chrome). Only inset and margin are genuine
10
+ // POSITIONING conflicts: UA's inset:0 leaves right/bottom pinned to 0 even
11
+ // after a component's inline `top`/`left` override the top/left portion, and
12
+ // UA's margin:auto re-centers inside that box — both fight the fixed
13
+ // coordinates each popup computes itself.
14
+ const STYLE_CSS = `
15
+ [data-ksui-toplayer]{inset:auto;margin:0;}
16
+ `;
17
+
18
+ /**
19
+ * Promotes `el` into the browser's top layer via the Popover API so it
20
+ * paints above a native <dialog> (Modal's default variant) regardless of
21
+ * z-index — an ordinary element can never out-paint a top-layer one.
22
+ * `"manual"` disables the API's own light-dismiss/Escape handling; every
23
+ * ksui popup keeps its own mousedown/Escape listeners unchanged.
24
+ *
25
+ * Call from the portaled panel's ref, mirroring useFocusTrap's shape:
26
+ * ref={(el) => { popupRef = el; onCleanup(useTopLayer(el)); }}
27
+ *
28
+ * showPopover() is deferred to onMount, not called inline here: this
29
+ * function runs synchronously inside <Portal>'s ref callback, which fires
30
+ * while the node is still detached (Portal appends its container to
31
+ * document.body *after* building the subtree) — el.isConnected is false
32
+ * and el.ownerDocument !== document at that point, so an inline call
33
+ * always throws InvalidStateError. onMount queues onto Solid's render-effect
34
+ * list, which only flushes once the whole synchronous render pass —
35
+ * including Portal's appendChild — has finished, so by the time it runs
36
+ * the node is guaranteed connected. (Same fix shape as Modal's DialogModal,
37
+ * which calls showModal() in onMount rather than in dialogEl's ref.)
38
+ */
39
+ export function useTopLayer(el: HTMLElement | undefined): () => void {
40
+ if (!el) return () => {};
41
+ injectCSS(STYLE_ID, STYLE_CSS);
42
+ el.setAttribute("popover", "manual");
43
+ el.setAttribute("data-ksui-toplayer", "");
44
+ onMount(() => {
45
+ // No Popover API (e.g. jsdom in unit tests) — nothing to promote, not an error.
46
+ if (typeof el.showPopover !== "function") return;
47
+ try {
48
+ el.showPopover();
49
+ } catch (err) {
50
+ // showPopover() throws if already open (rapid re-open); that's the only
51
+ // benign case left once we're guaranteed connected, and it's detectable
52
+ // because the element really is :popover-open despite the throw. Any
53
+ // other failure is real and must surface, not vanish into a no-op.
54
+ if (!el.matches(":popover-open")) {
55
+ console.error("[ksui] useTopLayer: showPopover() failed to promote element", err);
56
+ }
57
+ }
58
+ });
59
+ return () => {
60
+ try {
61
+ el.hidePopover();
62
+ } catch {
63
+ // already hidden, disconnected first, or API unavailable — harmless.
64
+ }
65
+ };
66
+ }