@kahitsan/ksui 0.10.2 → 0.12.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.
@@ -0,0 +1,930 @@
1
+ // Source: kserp src/components/ui/DataTable/DataTable.tsx (the host's Tailwind-
2
+ // classed table). Ported into ksui as a DOMAIN-FREE base primitive: a
3
+ // server-side (fetchFn) OR client-side (data) table with debounced search,
4
+ // column sort, pagination / "Show more" load mode, a filters JSX slot, an
5
+ // optional date filter, per-row expansion, and an onRefetch handle exposing
6
+ // { refetch, resetAndRefetch }.
7
+ //
8
+ // Like Button / Modal, ksui ships no sidecar .css — every Tailwind utility the
9
+ // host used is reproduced here as a runtime <style> tag (a stable id, injected
10
+ // once per page) referenced with plain, unscoped `ksui-datatable-*` class
11
+ // names. Surface / border / accent colors read from CSS custom properties
12
+ // (`--ksui-dt-*`) with the host's dark zinc + amber values as fallbacks, so a
13
+ // consumer can retint without forking. The component depends only on solid-js +
14
+ // lucide-solid plus ksui's own DatePicker — the same self-contained calendar
15
+ // popover the host DataTable used for its date filter (single + range), so the
16
+ // library carries no host primitive and no native `<input type="date">`.
17
+ //
18
+ // The public type surface (DataTableRow / DataTableColumn / FetchResult /
19
+ // FetchParams / DataTableProps) mirrors the kernel's `@kserp/host-ui` ambient
20
+ // contract EXACTLY, so a caller written against host-ui works unchanged here.
21
+ //
22
+ // Composition note: the date filter renders ksui's own `DatePicker` (a sibling
23
+ // base component) instead of a native `<input type="date">`, mirroring how the
24
+ // host DataTable wired its DatePicker. Importing one base into another technically
25
+ // makes this a composite under CONTRIBUTING's base/composite split; it stays in
26
+ // `base/` (and is re-exported as base) to preserve its existing import path —
27
+ // the only ksui dependency is DatePicker, itself a self-contained primitive.
28
+
29
+ import {
30
+ batch,
31
+ createSignal,
32
+ createMemo,
33
+ createEffect,
34
+ on,
35
+ untrack,
36
+ For,
37
+ Show,
38
+ type JSX,
39
+ type ParentComponent,
40
+ } from "solid-js";
41
+ import Search from "lucide-solid/icons/search";
42
+ import Filter from "lucide-solid/icons/filter";
43
+ import ChevronLeft from "lucide-solid/icons/chevron-left";
44
+ import ChevronRight from "lucide-solid/icons/chevron-right";
45
+ import ChevronDown from "lucide-solid/icons/chevron-down";
46
+ import ChevronUp from "lucide-solid/icons/chevron-up";
47
+ import ChevronsUpDown from "lucide-solid/icons/chevrons-up-down";
48
+ import DatePicker from "./DatePicker";
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Injected CSS
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const STYLE_ID = "ksui-datatable-style";
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Theming
58
+ // ---------------------------------------------------------------------------
59
+ //
60
+ // Every color in the injected stylesheet is driven by a `--ksui-dt-*` CSS
61
+ // custom property; the fallback after each `var(...)` is the host kserp
62
+ // DataTable's exact value (resolved from its Tailwind classes — zinc/amber
63
+ // dark theme). To retint, wrap the table in a container that sets the vars,
64
+ // e.g. `<div style={{ "--ksui-dt-card-bg": "#000", "--ksui-dt-accent": "#0af" }}>`.
65
+ //
66
+ // Full list of overridable vars (default = host value):
67
+ // --ksui-dt-card-bg outer card background
68
+ // (host `.card-bg`: linear-gradient(135deg,#0f0f0f,#1a1a1a))
69
+ // --ksui-dt-radius outer card / control corner radius (0.5rem)
70
+ // --ksui-dt-border card / header / footer / control border (zinc-800/50, rgba(39,39,42,0.5))
71
+ // --ksui-dt-row-border row divider + expansion-row top border (zinc-800/30, rgba(39,39,42,0.3))
72
+ // --ksui-dt-control-bg filter button / menu / select / search / show-more bg (zinc-900, #18181b)
73
+ // (the date filter is the ksui DatePicker; it reads the same vars)
74
+ // --ksui-dt-fg primary text: row/search text (zinc-200, #e4e4e7)
75
+ // --ksui-dt-fg-strong hover-to-full-contrast text on controls/pager (white, #ffffff)
76
+ // --ksui-dt-text secondary text: filter btn / select / pager nums+arrows (zinc-400, #a1a1aa)
77
+ // --ksui-dt-text-strong show-more label + sortable-header hover (zinc-300, #d4d4d8)
78
+ // --ksui-dt-muted header th / search icon+placeholder / empty / info text (zinc-500, #71717a)
79
+ // --ksui-dt-faint sort caret / per-row date separator / pager ellipsis (zinc-600, #52525b)
80
+ // --ksui-dt-row-hover row hover + pager hover bg (zinc-800/50, rgba(39,39,42,0.5))
81
+ // --ksui-dt-row-active row :active bg (zinc-800/70, rgba(39,39,42,0.7))
82
+ // --ksui-dt-expansion-bg per-row expansion panel bg (zinc-950/40, rgba(9,9,11,0.4))
83
+ // --ksui-dt-skeleton loading skeleton shimmer bg (zinc-800/50, rgba(39,39,42,0.5))
84
+ // --ksui-dt-accent active accent text: active pager num / show-more hover (amber-400, #fbbf24)
85
+ // --ksui-dt-accent-bg active pager num bg (amber-600/20, rgba(217,119,6,0.2))
86
+ // --ksui-dt-accent-border focus/hover accent border: search focus, show-more hover (amber-500/40, rgba(245,158,11,0.4))
87
+ //
88
+ const DATATABLE_CSS = `
89
+ .ksui-datatable{background:var(--ksui-dt-card-bg,linear-gradient(135deg,#0f0f0f 0%,#1a1a1a 100%));border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));border-radius:var(--ksui-dt-radius,0.5rem);color:var(--ksui-dt-fg,#e4e4e7);}
90
+ .ksui-datatable-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:1rem;}
91
+ .ksui-datatable-filters-inline{display:none;flex:1 1 0%;}
92
+ .ksui-datatable-filters-mobile{display:block;flex:1 1 0%;}
93
+ @media (min-width:768px){.ksui-datatable-filters-inline{display:block;}.ksui-datatable-filters-mobile{display:none;}}
94
+ .ksui-datatable-spacer{flex:1 1 0%;}
95
+ .ksui-datatable-controls{display:flex;align-items:center;gap:0.5rem;}
96
+ .ksui-datatable-filter-toggle-wrap{position:relative;}
97
+ .ksui-datatable-filter-toggle{display:inline-flex;cursor:pointer;align-items:center;gap:0.5rem;border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 0.75rem;font-size:0.75rem;line-height:1rem;color:var(--ksui-dt-text,#a1a1aa);transition:color 0.15s ease;}
98
+ .ksui-datatable-filter-toggle:hover{color:var(--ksui-dt-fg-strong,#ffffff);}
99
+ .ksui-datatable-filter-menu{position:absolute;left:0;top:100%;z-index:50;margin-top:0.5rem;border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.75rem;box-shadow:0 20px 25px -5px rgba(0,0,0,0.4),0 8px 10px -6px rgba(0,0,0,0.4);}
100
+ .ksui-datatable-select{border-radius:0.375rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem;font-size:0.75rem;line-height:1rem;color:var(--ksui-dt-text,#a1a1aa);}
101
+ .ksui-datatable-search-wrap{position:relative;}
102
+ .ksui-datatable-search-icon{position:absolute;left:0.75rem;top:50%;transform:translateY(-50%);color:var(--ksui-dt-muted,#71717a);pointer-events:none;}
103
+ .ksui-datatable-search-input{width:100%;border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 1rem 0.5rem 2.25rem;font-size:0.875rem;line-height:1.25rem;color:var(--ksui-dt-fg,#e4e4e7);outline:none;transition:border-color 0.15s ease;}
104
+ .ksui-datatable-search-input::placeholder{color:var(--ksui-dt-muted,#71717a);}
105
+ .ksui-datatable-search-input:focus{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));}
106
+ @media (min-width:640px){.ksui-datatable-search-input{width:18rem;}}
107
+ .ksui-datatable-scroll{overflow-x:auto;transition:opacity 0.15s ease;}
108
+ .ksui-datatable-table{width:100%;text-align:left;font-size:0.875rem;line-height:1.25rem;border-collapse:collapse;}
109
+ .ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--ksui-dt-muted,#71717a);}
110
+ .ksui-datatable-th{padding:0.75rem 1rem;}
111
+ .ksui-datatable-th-sortable{cursor:pointer;transition:color 0.15s ease;}
112
+ .ksui-datatable-th-sortable:hover{color:var(--ksui-dt-text-strong,#d4d4d8);}
113
+ .ksui-datatable-th-inner{display:inline-flex;align-items:center;}
114
+ .ksui-datatable-sort-icon{margin-left:0.25rem;display:inline-flex;color:var(--ksui-dt-faint,#52525b);}
115
+ .ksui-datatable-row{border-top:1px solid var(--ksui-dt-row-border,rgba(39,39,42,0.3));transition:background-color 0.15s ease;}
116
+ .ksui-datatable-row:hover{background-color:var(--ksui-dt-row-hover,rgba(39,39,42,0.5));}
117
+ .ksui-datatable-row-clickable{cursor:pointer;}
118
+ .ksui-datatable-row-clickable:active{background-color:var(--ksui-dt-row-active,rgba(39,39,42,0.7));}
119
+ .ksui-datatable-td{padding:0.75rem 1rem;}
120
+ .ksui-datatable-expansion-row{border-top:1px solid var(--ksui-dt-row-border,rgba(39,39,42,0.3));background-color:var(--ksui-dt-expansion-bg,rgba(9,9,11,0.4));}
121
+ .ksui-datatable-expansion-td{padding:0;}
122
+ .ksui-datatable-skeleton{height:1.25rem;width:100%;border-radius:0.25rem;background:var(--ksui-dt-skeleton,rgba(39,39,42,0.5));animation:ksuiDatatablePulse 1.5s cubic-bezier(0.4,0,0.6,1) infinite;}
123
+ @keyframes ksuiDatatablePulse{0%,100%{opacity:1;}50%{opacity:0.5;}}
124
+ .ksui-datatable-empty{padding:3rem 1rem;text-align:center;color:var(--ksui-dt-muted,#71717a);}
125
+ .ksui-datatable-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:1rem;}
126
+ .ksui-datatable-info{font-size:0.75rem;color:var(--ksui-dt-muted,#71717a);}
127
+ .ksui-datatable-showmore{border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 1rem;font-size:0.75rem;font-weight:500;color:var(--ksui-dt-text-strong,#d4d4d8);transition:border-color 0.15s ease,color 0.15s ease;cursor:pointer;}
128
+ .ksui-datatable-showmore:hover:not(:disabled){border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));color:var(--ksui-dt-accent,#fbbf24);}
129
+ .ksui-datatable-showmore:disabled{cursor:not-allowed;opacity:0.5;}
130
+ .ksui-datatable-pager{display:flex;align-items:center;gap:0.25rem;}
131
+ .ksui-datatable-pager-arrow{border-radius:0.25rem;padding:0.375rem;color:var(--ksui-dt-text,#a1a1aa);transition:background-color 0.15s ease,color 0.15s ease;background:transparent;border:0;cursor:pointer;display:inline-flex;}
132
+ .ksui-datatable-pager-arrow:hover:not(:disabled){background-color:var(--ksui-dt-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg-strong,#ffffff);}
133
+ .ksui-datatable-pager-arrow:disabled{cursor:not-allowed;opacity:0.3;}
134
+ .ksui-datatable-pager-num{border-radius:0.25rem;padding:0.25rem 0.625rem;font-size:0.75rem;color:var(--ksui-dt-text,#a1a1aa);transition:background-color 0.15s ease,color 0.15s ease;background:transparent;border:0;cursor:pointer;}
135
+ .ksui-datatable-pager-num:hover{background-color:var(--ksui-dt-row-hover,rgba(39,39,42,0.5));color:var(--ksui-dt-fg-strong,#ffffff);}
136
+ .ksui-datatable-pager-num-active{background-color:var(--ksui-dt-accent-bg,rgba(217,119,6,0.2));font-weight:500;color:var(--ksui-dt-accent,#fbbf24);}
137
+ .ksui-datatable-pager-num-active:hover{background-color:var(--ksui-dt-accent-bg,rgba(217,119,6,0.2));color:var(--ksui-dt-accent,#fbbf24);}
138
+ .ksui-datatable-pager-ellipsis{padding:0 0.375rem;font-size:0.75rem;color:var(--ksui-dt-faint,#52525b);}
139
+ `;
140
+
141
+ function ensureDataTableStyle(): void {
142
+ if (typeof document === "undefined") return;
143
+ if (document.getElementById(STYLE_ID)) return;
144
+ const el = document.createElement("style");
145
+ el.id = STYLE_ID;
146
+ el.textContent = DATATABLE_CSS;
147
+ document.head.appendChild(el);
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Types (mirror the kernel's @kserp/host-ui contract exactly)
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export interface DataTableRow {
155
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
156
+ [key: string]: any;
157
+ }
158
+
159
+ export interface DataTableColumn<T extends DataTableRow> {
160
+ /** Property key on the row object. null for computed/action columns. */
161
+ data: (keyof T & string) | null;
162
+ title?: string;
163
+ render?: (
164
+ data: T[keyof T] | null,
165
+ type: "display",
166
+ row: T,
167
+ meta: { row: number; col: number; search: string },
168
+ ) => JSX.Element | string;
169
+ orderable?: boolean;
170
+ className?: string;
171
+ }
172
+
173
+ /** Generic fetch result returned by fetchFn */
174
+ export interface FetchResult<T> {
175
+ data: T[];
176
+ total: number;
177
+ }
178
+
179
+ /** Parameters passed to fetchFn */
180
+ export interface FetchParams {
181
+ page: number;
182
+ limit: number;
183
+ search: string;
184
+ sortBy: string | null;
185
+ sortDir: "asc" | "desc";
186
+ /**
187
+ * ISO date string (YYYY-MM-DD) when single-date `dateField` filtering is active.
188
+ * Null when range mode is active (use `dateFrom` / `dateTo` instead) or no filter is set.
189
+ */
190
+ dateFilter: string | null;
191
+ /** ISO start date when `dateRangeMode` is on. Null when range mode is off or unset. */
192
+ dateFrom?: string | null;
193
+ /** ISO end date when `dateRangeMode` is on. Null when range mode is off or unset. */
194
+ dateTo?: string | null;
195
+ }
196
+
197
+ export interface DataTableProps<T extends DataTableRow> {
198
+ /**
199
+ * Generic data fetcher. Return { data, total }.
200
+ * When provided, the table runs in server-side mode automatically.
201
+ */
202
+ fetchFn?: (params: FetchParams) => Promise<FetchResult<T>>;
203
+
204
+ /** Static data (client-side mode). Ignored when fetchFn is set. */
205
+ data?: T[];
206
+
207
+ columns?: DataTableColumn<T>[];
208
+
209
+ /**
210
+ * Reactive key that triggers a refetch + page-1 reset when it changes.
211
+ * Typical usage: `refetchKey={() => activeWorkspace()?.ws_id}`.
212
+ */
213
+ refetchKey?: () => unknown;
214
+
215
+ searching?: boolean;
216
+ ordering?: boolean;
217
+ paging?: boolean;
218
+ info?: boolean;
219
+
220
+ pageLength?: number;
221
+ lengthMenu?: number[];
222
+
223
+ /** Search input placeholder */
224
+ searchPlaceholder?: string;
225
+
226
+ /**
227
+ * Replace the paginated footer with a "Show more" button. Fetched chunks
228
+ * accumulate into the table instead of replacing each other. When a chunk
229
+ * comes back with fewer rows than `pageLength` (e.g. the consumer's fetchFn
230
+ * collapses or filters rows post-fetch) and there is more data on the
231
+ * server, the table auto-loads the next chunk up to a small cap; after
232
+ * that the user clicks Show more to keep going. Default false: paginate
233
+ * normally.
234
+ */
235
+ loadMore?: boolean;
236
+
237
+ /** Class for the outer container */
238
+ class?: string;
239
+
240
+ /** Debounce delay in ms for search input (default 300) */
241
+ debounceDelay?: number;
242
+
243
+ /** Empty state message */
244
+ emptyMessage?: string;
245
+
246
+ /** No-results message (when search returns 0) */
247
+ noResultsMessage?: string;
248
+
249
+ /** Content rendered inside the card above the header bar (e.g. filter buttons) */
250
+ filters?: JSX.Element;
251
+
252
+ /** Column key containing date values. Enables a date input to filter by date. */
253
+ dateField?: keyof T & string;
254
+
255
+ /**
256
+ * Switch the date filter to range mode. When true, the date control picks a
257
+ * start/end pair, and `fetchFn` receives `dateFrom` / `dateTo` instead of a single
258
+ * `dateFilter`. Requires `dateField` to be set.
259
+ */
260
+ dateRangeMode?: boolean;
261
+
262
+ /**
263
+ * Optional pre-render transform applied to the accumulated table data.
264
+ * Runs over the fetched rows in a memo, so its output recomputes whenever
265
+ * the underlying rows change OR the transform's reactive dependencies
266
+ * (signals it reads) change, without re-invoking `fetchFn`.
267
+ */
268
+ transformRows?: (rows: T[]) => T[];
269
+
270
+ /** Called when data is refreshed (after fetch completes) */
271
+ onDataChange?: (data: T[]) => void;
272
+
273
+ /**
274
+ * Expose a refetch function to parent. The callback receives an object
275
+ * with two members:
276
+ * - `refetch()` triggers a fresh fetch with the current page/state.
277
+ * - `resetAndRefetch()` resets pagination state (page -> 1, loadMore
278
+ * accumulators cleared, fetch-dedupe cache cleared) and then fetches
279
+ * page 1.
280
+ */
281
+ onRefetch?: (api: { refetch: () => void; resetAndRefetch: () => void }) => void;
282
+
283
+ /** Called when a data row is clicked */
284
+ onRowClick?: (row: T) => void;
285
+
286
+ /**
287
+ * Optional per-row expansion panel. Returns a JSX node to render inside a
288
+ * full-width row directly under the main row, or null/undefined to skip.
289
+ * The expansion row spans every column.
290
+ */
291
+ expansionContent?: (row: T) => JSX.Element | null | undefined;
292
+ }
293
+
294
+ // Mobile filter dropdown -- collapses the filter slot into a toggle menu.
295
+ const FilterDropdown: ParentComponent = (props) => {
296
+ const [open, setOpen] = createSignal(false);
297
+ return (
298
+ <div class="ksui-datatable-filter-toggle-wrap">
299
+ <button onClick={() => setOpen(!open())} class="ksui-datatable-filter-toggle">
300
+ <Filter size={14} />
301
+ Filter
302
+ </button>
303
+ <Show when={open()}>
304
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
305
+ <div class="ksui-datatable-filter-menu" onClick={() => setOpen(false)}>
306
+ {props.children}
307
+ </div>
308
+ </Show>
309
+ </div>
310
+ );
311
+ };
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Component
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export function DataTable<T extends DataTableRow>(props: DataTableProps<T>): JSX.Element {
318
+ ensureDataTableStyle();
319
+
320
+ const columns = () => props.columns || [];
321
+ const searching = () => props.searching ?? true;
322
+ const ordering = () => props.ordering ?? true;
323
+ const paging = () => props.paging ?? true;
324
+ const info = () => props.info ?? true;
325
+ const pageLength = () => props.pageLength ?? 10;
326
+ const lengthMenu = () => props.lengthMenu ?? [10, 25, 50, 100];
327
+ const className = () => props.class || "";
328
+ const debounceDelay = () => props.debounceDelay ?? 300;
329
+ const isServerSide = () => !!props.fetchFn;
330
+ const loadMoreMode = () => props.loadMore === true;
331
+
332
+ // State
333
+ const [searchTerm, setSearchTerm] = createSignal("");
334
+ const [currentPage, setCurrentPage] = createSignal(1);
335
+ const [itemsPerPage, setItemsPerPage] = createSignal(pageLength());
336
+ const [sortBy, setSortBy] = createSignal<string | null>(null);
337
+ const [sortDir, setSortDir] = createSignal<"asc" | "desc">("desc");
338
+ const [dateFilter, setDateFilter] = createSignal<string | null>(null);
339
+ const [dateFrom, setDateFrom] = createSignal<string | null>(null);
340
+ const [dateTo, setDateTo] = createSignal<string | null>(null);
341
+
342
+ const [tableData, setTableData] = createSignal<T[]>([]);
343
+ const [totalRecords, setTotalRecords] = createSignal(0);
344
+ const [loading, setLoading] = createSignal(false);
345
+ const [initialized, setInitialized] = createSignal(false);
346
+
347
+ // ---- Server-side fetching ----
348
+
349
+ let debounceTimer: ReturnType<typeof setTimeout>;
350
+ // (page, epoch) tuples this DataTable has already started a fetch for in
351
+ // loadMore mode. A second effect run with the same key skips entirely so
352
+ // the chunk is never appended twice. Reset on every resetLoadMore() call.
353
+ const processedFetchKeys = new Set<string>();
354
+ const [fetchTrigger, setFetchTrigger] = createSignal(0);
355
+ // Number of API records consumed across all chunks since the last reset.
356
+ const [apiConsumed, setApiConsumed] = createSignal(0);
357
+ // Auto-load attempts since the last reset, capped to keep a degenerate
358
+ // grouping from hammering the API.
359
+ const [autoLoadCount, setAutoLoadCount] = createSignal(0);
360
+ // Monotonic id bumped on every reset; each fetch closes over the epoch active
361
+ // at request time and discards itself on completion if the epoch moved on.
362
+ const [loadEpoch, setLoadEpoch] = createSignal(0);
363
+ // Visible-row target the auto-load chain works toward.
364
+ const [loadTarget, setLoadTarget] = createSignal(pageLength());
365
+ // True between a manual Show-more click and the moment its fetch returns.
366
+ const [pendingLoadTargetBump, setPendingLoadTargetBump] = createSignal(false);
367
+ const AUTO_LOAD_CAP = 5;
368
+
369
+ function triggerFetch() {
370
+ setFetchTrigger((n) => n + 1);
371
+ }
372
+
373
+ function resetLoadMore() {
374
+ if (!loadMoreMode()) return;
375
+ processedFetchKeys.clear();
376
+ setAutoLoadCount(0);
377
+ setApiConsumed(0);
378
+ // Also clear totalRecords so the auto-load effect doesn't fire between this
379
+ // reset and the next fetch's response.
380
+ setTotalRecords(0);
381
+ setLoadTarget(itemsPerPage());
382
+ setPendingLoadTargetBump(false);
383
+ setLoadEpoch((e) => e + 1);
384
+ }
385
+
386
+ // Expose refetch to parent
387
+ props.onRefetch?.({
388
+ refetch: () => triggerFetch(),
389
+ resetAndRefetch: () => {
390
+ batch(() => {
391
+ setCurrentPage(1);
392
+ resetLoadMore();
393
+ triggerFetch();
394
+ });
395
+ },
396
+ });
397
+
398
+ // Watch refetchKey — when it changes, reset to page 1 and refetch.
399
+ createEffect(
400
+ on(
401
+ () => props.refetchKey?.(),
402
+ () => {
403
+ batch(() => {
404
+ setCurrentPage(1);
405
+ resetLoadMore();
406
+ triggerFetch();
407
+ });
408
+ },
409
+ { defer: true },
410
+ ),
411
+ );
412
+
413
+ createEffect(() => {
414
+ void fetchTrigger(); // track
415
+ if (!props.fetchFn) return;
416
+
417
+ const fn = props.fetchFn;
418
+ const limit = itemsPerPage();
419
+ const lm = loadMoreMode();
420
+ const page = currentPage();
421
+ const epoch = loadEpoch();
422
+ const params: FetchParams = {
423
+ page,
424
+ limit,
425
+ search: searchTerm(),
426
+ sortBy: sortBy(),
427
+ sortDir: sortDir(),
428
+ dateFilter: props.dateRangeMode ? null : dateFilter(),
429
+ dateFrom: props.dateRangeMode ? dateFrom() : null,
430
+ dateTo: props.dateRangeMode ? dateTo() : null,
431
+ };
432
+
433
+ if (lm) {
434
+ const key = `${page}:${epoch}`;
435
+ if (processedFetchKeys.has(key)) return;
436
+ processedFetchKeys.add(key);
437
+ }
438
+
439
+ setLoading(true);
440
+ fn(params)
441
+ .then((result) => {
442
+ // In loadMore mode, drop a stale response if the user has reset
443
+ // (search/sort/filter) while this fetch was in flight.
444
+ if (lm && epoch !== loadEpoch()) {
445
+ return;
446
+ }
447
+ if (lm && page > 1) {
448
+ // Append next chunk
449
+ setTableData((prev) => [...(prev as T[]), ...(result.data as T[])] as T[]);
450
+ } else {
451
+ // Replace (paginated mode, or first chunk in loadMore)
452
+ setTableData(result.data as T[]);
453
+ }
454
+ setTotalRecords(result.total);
455
+ if (lm) {
456
+ setApiConsumed((prev) => Math.min(prev + limit, result.total));
457
+ if (page > 1 && untrack(pendingLoadTargetBump)) {
458
+ setLoadTarget((t) => t + itemsPerPage());
459
+ setPendingLoadTargetBump(false);
460
+ }
461
+ }
462
+ setInitialized(true);
463
+ props.onDataChange?.(result.data);
464
+ })
465
+ .catch((err) => {
466
+ console.error("[DataTable] fetch error:", err);
467
+ if (!lm || page === 1) {
468
+ setTableData([]);
469
+ setTotalRecords(0);
470
+ return;
471
+ }
472
+ // page > 1 failure in loadMore mode: rewind so a retry can re-fetch
473
+ // the same page.
474
+ if (epoch === loadEpoch()) {
475
+ processedFetchKeys.delete(`${page}:${epoch}`);
476
+ setCurrentPage((p) => (p > 1 ? p - 1 : p));
477
+ }
478
+ })
479
+ .finally(() => setLoading(false));
480
+ });
481
+
482
+ // ---- Auto-load (loadMore mode) ----
483
+ //
484
+ // Fires only when a freshly fetched server chunk left us short of the
485
+ // visible-row target. Gated on `apiConsumed` (raw rows the server has handed
486
+ // us so far) — NOT on `paginatedData().length`, the post-transform visible
487
+ // count — so a transformRows-driven shrink in the visible list never fires a
488
+ // network fetch. The autoLoadCount cap protects a degenerate grouping from
489
+ // chaining to the end of the dataset.
490
+ createEffect(
491
+ on([apiConsumed, loading], ([, isLoading]) => {
492
+ if (!loadMoreMode()) return;
493
+ if (untrack(initialized) === false) return;
494
+ if (isLoading) return;
495
+ const visible = untrack(() => paginatedData().length);
496
+ if (visible >= untrack(loadTarget)) return;
497
+ if (untrack(apiConsumed) >= untrack(totalRecords)) return;
498
+ if (untrack(autoLoadCount) >= AUTO_LOAD_CAP) return;
499
+ batch(() => {
500
+ setAutoLoadCount((c) => c + 1);
501
+ setCurrentPage((p) => p + 1);
502
+ triggerFetch();
503
+ });
504
+ }),
505
+ );
506
+
507
+ // ---- Client-side data ----
508
+
509
+ createEffect(() => {
510
+ if (isServerSide()) return;
511
+ const d = props.data || [];
512
+ setTableData(d as T[]);
513
+ setTotalRecords(d.length);
514
+ setInitialized(true);
515
+ });
516
+
517
+ // Post-fetch row transform. No-op passthrough when no transform is supplied.
518
+ const transformedData = createMemo(() => {
519
+ const transform = props.transformRows;
520
+ const rows = tableData() as T[];
521
+ return transform ? transform(rows) : rows;
522
+ });
523
+
524
+ // Client-side date filtering
525
+ const dateFilteredData = createMemo(() => {
526
+ if (isServerSide()) return transformedData();
527
+ const field = props.dateField;
528
+ if (!field) return transformedData();
529
+ if (props.dateRangeMode) {
530
+ const from = dateFrom();
531
+ const to = dateTo();
532
+ if (!from && !to) return transformedData();
533
+ return transformedData().filter((row) => {
534
+ const val = row[field];
535
+ if (!val) return false;
536
+ const day = String(val).slice(0, 10);
537
+ if (from && day < from) return false;
538
+ if (to && day > to) return false;
539
+ return true;
540
+ });
541
+ }
542
+ const df = dateFilter();
543
+ if (!df) return transformedData();
544
+ return transformedData().filter((row) => {
545
+ const val = row[field];
546
+ if (!val) return false;
547
+ return String(val).startsWith(df);
548
+ });
549
+ });
550
+
551
+ // Client-side search filtering
552
+ const filteredData = createMemo(() => {
553
+ if (isServerSide()) return dateFilteredData();
554
+ if (!searching() || !searchTerm()) return dateFilteredData();
555
+ const q = searchTerm().toLowerCase();
556
+ return dateFilteredData().filter((row) =>
557
+ Object.values(row).some((v) =>
558
+ String(v ?? "")
559
+ .toLowerCase()
560
+ .includes(q),
561
+ ),
562
+ );
563
+ });
564
+
565
+ // Client-side sorting
566
+ const sortedData = createMemo(() => {
567
+ if (isServerSide()) return filteredData();
568
+ const key = sortBy();
569
+ if (!ordering() || !key) return filteredData();
570
+ const dir = sortDir();
571
+ return [...filteredData()].sort((a, b) => {
572
+ const av = a[key],
573
+ bv = b[key];
574
+ if (av < bv) return dir === "asc" ? -1 : 1;
575
+ if (av > bv) return dir === "asc" ? 1 : -1;
576
+ return 0;
577
+ });
578
+ });
579
+
580
+ // Client-side pagination
581
+ const paginatedData = createMemo(() => {
582
+ if (isServerSide() || !paging()) return sortedData();
583
+ const start = (currentPage() - 1) * itemsPerPage();
584
+ return sortedData().slice(start, start + itemsPerPage());
585
+ });
586
+
587
+ const displayTotal = createMemo(() => (isServerSide() ? totalRecords() : sortedData().length));
588
+
589
+ const totalPages = createMemo(() => Math.ceil(displayTotal() / itemsPerPage()) || 1);
590
+
591
+ // ---- Handlers ----
592
+
593
+ function handleDateFilter(date: string | null) {
594
+ batch(() => {
595
+ setDateFilter(date);
596
+ setCurrentPage(1);
597
+ resetLoadMore();
598
+ if (isServerSide()) triggerFetch();
599
+ });
600
+ }
601
+
602
+ function handleDateRangeFilter(range: { start: string | null; end: string | null }) {
603
+ batch(() => {
604
+ setDateFrom(range.start);
605
+ setDateTo(range.end);
606
+ setCurrentPage(1);
607
+ resetLoadMore();
608
+ if (isServerSide()) triggerFetch();
609
+ });
610
+ }
611
+
612
+ function handleSearch(value: string) {
613
+ batch(() => {
614
+ setSearchTerm(value);
615
+ setCurrentPage(1);
616
+ resetLoadMore();
617
+ });
618
+ if (isServerSide()) {
619
+ clearTimeout(debounceTimer);
620
+ debounceTimer = setTimeout(triggerFetch, debounceDelay());
621
+ }
622
+ }
623
+
624
+ function handleSort(key: string) {
625
+ if (!ordering()) return;
626
+ batch(() => {
627
+ if (sortBy() === key) {
628
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
629
+ } else {
630
+ setSortBy(key);
631
+ setSortDir("desc");
632
+ }
633
+ setCurrentPage(1);
634
+ resetLoadMore();
635
+ if (isServerSide()) triggerFetch();
636
+ });
637
+ }
638
+
639
+ function handlePageChange(page: number) {
640
+ setCurrentPage(page);
641
+ if (isServerSide()) triggerFetch();
642
+ }
643
+
644
+ function handlePageSize(size: number) {
645
+ batch(() => {
646
+ setItemsPerPage(size);
647
+ setCurrentPage(1);
648
+ resetLoadMore();
649
+ if (isServerSide()) triggerFetch();
650
+ });
651
+ }
652
+
653
+ function handleShowMore() {
654
+ if (!loadMoreMode() || loading()) return;
655
+ if (apiConsumed() >= totalRecords()) return;
656
+ batch(() => {
657
+ setCurrentPage((p) => p + 1);
658
+ setAutoLoadCount(0);
659
+ setPendingLoadTargetBump(true);
660
+ triggerFetch();
661
+ });
662
+ }
663
+
664
+ // ---- Render helpers ----
665
+
666
+ function renderCell(col: DataTableColumn<T>, row: T, rowIdx: number): JSX.Element | string {
667
+ if (col.render) {
668
+ const val = col.data ? row[col.data] : null;
669
+ return col.render(val, "display", row, {
670
+ row: rowIdx,
671
+ col: columns().indexOf(col),
672
+ search: searchTerm(),
673
+ });
674
+ }
675
+ return String(col.data ? (row[col.data] ?? "") : "");
676
+ }
677
+
678
+ function getHeader(col: DataTableColumn<T>) {
679
+ if (col.title) return col.title;
680
+ if (col.data) return col.data.charAt(0).toUpperCase() + col.data.slice(1);
681
+ return "";
682
+ }
683
+
684
+ function isSortable(col: DataTableColumn<T>) {
685
+ return ordering() && col.orderable !== false && col.data !== null;
686
+ }
687
+
688
+ function pageNumbers(): (number | "...")[] {
689
+ const tp = totalPages();
690
+ const cp = currentPage();
691
+ if (tp <= 7) return Array.from({ length: tp }, (_, i) => i + 1);
692
+ const pages: (number | "...")[] = [1];
693
+ if (cp > 3) pages.push("...");
694
+ for (let i = Math.max(2, cp - 1); i <= Math.min(tp - 1, cp + 1); i++) pages.push(i);
695
+ if (cp < tp - 2) pages.push("...");
696
+ pages.push(tp);
697
+ return pages;
698
+ }
699
+
700
+ // ---- JSX ----
701
+
702
+ return (
703
+ <div class={`ksui-datatable ${className()}`}>
704
+ {/* Header bar: filters + date + per-page + search in one row */}
705
+ <Show when={props.filters || searching() || paging()}>
706
+ <div class="ksui-datatable-header">
707
+ {/* Filters: inline on md+, dropdown on mobile */}
708
+ <Show when={props.filters}>
709
+ <div class="ksui-datatable-filters-inline">{props.filters}</div>
710
+ <div class="ksui-datatable-filters-mobile">
711
+ <FilterDropdown>{props.filters}</FilterDropdown>
712
+ </div>
713
+ </Show>
714
+ <Show when={!props.filters}>
715
+ <div class="ksui-datatable-spacer" />
716
+ </Show>
717
+
718
+ {/* Right side: date filter + per-page + search */}
719
+ <div class="ksui-datatable-controls">
720
+ <Show when={props.dateField && !props.dateRangeMode}>
721
+ <DatePicker value={dateFilter()} onChange={handleDateFilter} />
722
+ </Show>
723
+ <Show when={props.dateField && props.dateRangeMode}>
724
+ <DatePicker
725
+ range={true}
726
+ value={{ start: dateFrom(), end: dateTo() }}
727
+ onChange={handleDateRangeFilter}
728
+ />
729
+ </Show>
730
+ <Show when={paging() && lengthMenu().length > 1}>
731
+ <select
732
+ value={itemsPerPage()}
733
+ onChange={(e) => handlePageSize(Number(e.currentTarget.value))}
734
+ class="ksui-datatable-select"
735
+ >
736
+ <For each={lengthMenu()}>
737
+ {(size) => <option value={size}>{size} per page</option>}
738
+ </For>
739
+ </select>
740
+ </Show>
741
+ <Show when={searching()}>
742
+ <div class="ksui-datatable-search-wrap">
743
+ <Search size={16} class="ksui-datatable-search-icon" />
744
+ <input
745
+ type="text"
746
+ placeholder={props.searchPlaceholder || "Search..."}
747
+ value={searchTerm()}
748
+ onInput={(e) => handleSearch(e.currentTarget.value)}
749
+ class="ksui-datatable-search-input"
750
+ />
751
+ </div>
752
+ </Show>
753
+ </div>
754
+ </div>
755
+ </Show>
756
+
757
+ {/* Table */}
758
+ <div
759
+ class="ksui-datatable-scroll"
760
+ style={{ opacity: loading() && initialized() ? 0.5 : 1 }}
761
+ >
762
+ <table class="ksui-datatable-table">
763
+ <thead class="ksui-datatable-thead">
764
+ <tr>
765
+ <For each={columns()}>
766
+ {(col) => (
767
+ <th
768
+ class={`ksui-datatable-th ${isSortable(col) ? "ksui-datatable-th-sortable" : ""} ${col.className || ""}`}
769
+ onClick={() => isSortable(col) && col.data && handleSort(col.data)}
770
+ >
771
+ <span class="ksui-datatable-th-inner">
772
+ {getHeader(col)}
773
+ <Show when={isSortable(col)}>
774
+ <span class="ksui-datatable-sort-icon">
775
+ <Show
776
+ when={sortBy() === col.data}
777
+ fallback={<ChevronsUpDown size={12} />}
778
+ >
779
+ <Show when={sortDir() === "asc"} fallback={<ChevronDown size={12} />}>
780
+ <ChevronUp size={12} />
781
+ </Show>
782
+ </Show>
783
+ </span>
784
+ </Show>
785
+ </span>
786
+ </th>
787
+ )}
788
+ </For>
789
+ </tr>
790
+ </thead>
791
+ <tbody>
792
+ {/* Loading skeleton (first load only) */}
793
+ <Show when={loading() && !initialized()}>
794
+ <For each={Array(itemsPerPage())}>
795
+ {() => (
796
+ <tr class="ksui-datatable-row">
797
+ <For each={columns()}>
798
+ {() => (
799
+ <td class="ksui-datatable-td">
800
+ <div class="ksui-datatable-skeleton" />
801
+ </td>
802
+ )}
803
+ </For>
804
+ </tr>
805
+ )}
806
+ </For>
807
+ </Show>
808
+
809
+ {/* Empty state */}
810
+ <Show when={initialized() && !loading() && paginatedData().length === 0}>
811
+ <tr>
812
+ <td colSpan={columns().length} class="ksui-datatable-empty">
813
+ {searchTerm()
814
+ ? props.noResultsMessage || "No results found."
815
+ : props.emptyMessage || "No data available."}
816
+ </td>
817
+ </tr>
818
+ </Show>
819
+
820
+ {/* Data rows */}
821
+ <Show when={initialized() && paginatedData().length > 0}>
822
+ <For each={paginatedData()}>
823
+ {(row, rowIndex) => {
824
+ const expansion = () => props.expansionContent?.(row);
825
+ return (
826
+ <>
827
+ <tr
828
+ class="ksui-datatable-row"
829
+ classList={{ "ksui-datatable-row-clickable": !!props.onRowClick }}
830
+ onClick={() => props.onRowClick?.(row)}
831
+ >
832
+ <For each={columns()}>
833
+ {(col) => (
834
+ <td class={`ksui-datatable-td ${col.className || ""}`}>
835
+ {renderCell(col, row, rowIndex())}
836
+ </td>
837
+ )}
838
+ </For>
839
+ </tr>
840
+ <Show when={expansion()}>
841
+ <tr
842
+ // role="presentation" strips the implicit row role so
843
+ // callers' `getByRole('row')` queries don't double-match
844
+ // this wrapper AND its nested sub-rows.
845
+ role="presentation"
846
+ class="ksui-datatable-expansion-row"
847
+ data-testid="datatable-expansion-row"
848
+ >
849
+ <td class="ksui-datatable-expansion-td" colSpan={columns().length}>
850
+ {expansion()}
851
+ </td>
852
+ </tr>
853
+ </Show>
854
+ </>
855
+ );
856
+ }}
857
+ </For>
858
+ </Show>
859
+ </tbody>
860
+ </table>
861
+ </div>
862
+
863
+ {/* Footer: Show more (loadMore mode) */}
864
+ <Show when={loadMoreMode() && initialized()}>
865
+ <div data-testid="datatable-show-more-footer" class="ksui-datatable-footer">
866
+ <Show when={info()}>
867
+ <span class="ksui-datatable-info">
868
+ Showing {paginatedData().length} of {displayTotal()}
869
+ </span>
870
+ </Show>
871
+ <Show when={apiConsumed() < totalRecords()}>
872
+ <button
873
+ data-testid="datatable-show-more-btn"
874
+ disabled={loading()}
875
+ onClick={handleShowMore}
876
+ class="ksui-datatable-showmore"
877
+ >
878
+ {loading() ? "Loading..." : "Show more"}
879
+ </button>
880
+ </Show>
881
+ </div>
882
+ </Show>
883
+
884
+ {/* Footer: pagination */}
885
+ <Show when={!loadMoreMode() && paging() && totalPages() > 1}>
886
+ <div class="ksui-datatable-footer">
887
+ <Show when={info()}>
888
+ <span class="ksui-datatable-info">
889
+ Page {currentPage()} of {totalPages()} ({displayTotal()} results)
890
+ </span>
891
+ </Show>
892
+
893
+ <div class="ksui-datatable-pager">
894
+ <button
895
+ disabled={currentPage() <= 1}
896
+ onClick={() => handlePageChange(currentPage() - 1)}
897
+ class="ksui-datatable-pager-arrow"
898
+ >
899
+ <ChevronLeft size={16} />
900
+ </button>
901
+ <For each={pageNumbers()}>
902
+ {(p) => (
903
+ <Show
904
+ when={p !== "..."}
905
+ fallback={<span class="ksui-datatable-pager-ellipsis">...</span>}
906
+ >
907
+ <button
908
+ class={`ksui-datatable-pager-num ${currentPage() === p ? "ksui-datatable-pager-num-active" : ""}`}
909
+ onClick={() => handlePageChange(p as number)}
910
+ >
911
+ {p}
912
+ </button>
913
+ </Show>
914
+ )}
915
+ </For>
916
+ <button
917
+ disabled={currentPage() >= totalPages()}
918
+ onClick={() => handlePageChange(currentPage() + 1)}
919
+ class="ksui-datatable-pager-arrow"
920
+ >
921
+ <ChevronRight size={16} />
922
+ </button>
923
+ </div>
924
+ </div>
925
+ </Show>
926
+ </div>
927
+ );
928
+ }
929
+
930
+ export default DataTable;