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