@talelk/newui-organisms 0.0.0-stage → 2.0.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.
Files changed (64) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +12 -2
  3. package/package.json +38 -3
  4. package/src/exports.test.ts +27 -0
  5. package/src/index.ts +1 -0
  6. package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
  7. package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
  8. package/src/organisms/AppSidebar/index.ts +1 -0
  9. package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
  10. package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
  11. package/src/organisms/AuthCard/AuthCard.tsx +89 -0
  12. package/src/organisms/AuthCard/index.ts +1 -0
  13. package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
  14. package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
  15. package/src/organisms/CommandPalette/index.ts +1 -0
  16. package/src/organisms/DataState/DataState.test.tsx +45 -0
  17. package/src/organisms/DataState/DataState.tsx +111 -0
  18. package/src/organisms/DataState/index.ts +6 -0
  19. package/src/organisms/DataTable/DataTable.test.tsx +161 -0
  20. package/src/organisms/DataTable/DataTable.tsx +612 -0
  21. package/src/organisms/DataTable/index.ts +1 -0
  22. package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
  23. package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
  24. package/src/organisms/DetailHeader/index.ts +1 -0
  25. package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
  26. package/src/organisms/FilterBar/FilterBar.tsx +251 -0
  27. package/src/organisms/FilterBar/index.ts +1 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
  29. package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
  30. package/src/organisms/KanbanBoard/drag.test.ts +448 -0
  31. package/src/organisms/KanbanBoard/drag.ts +407 -0
  32. package/src/organisms/KanbanBoard/index.ts +17 -0
  33. package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
  34. package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
  35. package/src/organisms/MobileNav/MobileNav.tsx +120 -0
  36. package/src/organisms/MobileNav/index.ts +6 -0
  37. package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
  38. package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
  39. package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
  40. package/src/organisms/PageToolbar/index.ts +1 -0
  41. package/src/organisms/Section/Section.test.tsx +41 -0
  42. package/src/organisms/Section/Section.tsx +108 -0
  43. package/src/organisms/Section/index.ts +1 -0
  44. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  45. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  46. package/src/organisms/SelectionBar/index.ts +5 -0
  47. package/src/organisms/SettingsDialog/SettingsDialog.a11y.test.tsx +58 -0
  48. package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
  49. package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
  50. package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
  51. package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
  52. package/src/organisms/SettingsDialog/index.ts +20 -0
  53. package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
  54. package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
  55. package/src/organisms/Sidebar/Sidebar.tsx +732 -0
  56. package/src/organisms/Sidebar/index.ts +22 -0
  57. package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
  58. package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
  59. package/src/organisms/StatGrid/StatGrid.tsx +151 -0
  60. package/src/organisms/StatGrid/index.ts +1 -0
  61. package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
  62. package/src/organisms/VirtualList/VirtualList.tsx +113 -0
  63. package/src/organisms/VirtualList/index.ts +1 -0
  64. package/src/organisms/index.ts +16 -0
@@ -0,0 +1,612 @@
1
+ import { Button, Checkbox, cn, TextInput } from '@talelk/newui-atoms'
2
+ import {
3
+ Pagination,
4
+ ScrollTable,
5
+ ScrollTableHead,
6
+ } from '@talelk/newui-molecules'
7
+ import {
8
+ type ColumnDef,
9
+ columnFilteringFeature,
10
+ columnVisibilityFeature,
11
+ createFilteredRowModel,
12
+ createPaginatedRowModel,
13
+ createSortedRowModel,
14
+ createTable,
15
+ flexRender,
16
+ globalFilteringFeature,
17
+ rowPaginationFeature,
18
+ rowSelectionFeature,
19
+ rowSortingFeature,
20
+ sortFn_alphanumeric,
21
+ sortFn_datetime,
22
+ sortFn_text,
23
+ tableFeatures,
24
+ } from '@tanstack/solid-table'
25
+ import {
26
+ createEffect,
27
+ createMemo,
28
+ For,
29
+ type JSX,
30
+ on,
31
+ Show,
32
+ untrack,
33
+ } from 'solid-js'
34
+ import { DataState } from '../DataState'
35
+ import { SelectionBar } from '../SelectionBar'
36
+
37
+ /**
38
+ * Built on the first table, not when the module is evaluated.
39
+ *
40
+ * A page can import this entry for `ScrollTable` alone (the accounting ledger
41
+ * does); if the feature set were a module-level `const`, evaluating the module
42
+ * would build it — and rollup could not drop the table engine behind it.
43
+ */
44
+ function createFeatures() {
45
+ return tableFeatures({
46
+ columnFilteringFeature,
47
+ columnVisibilityFeature,
48
+ globalFilteringFeature,
49
+ rowPaginationFeature,
50
+ rowSelectionFeature,
51
+ rowSortingFeature,
52
+ filteredRowModel: createFilteredRowModel(),
53
+ paginatedRowModel: createPaginatedRowModel(),
54
+ sortedRowModel: createSortedRowModel(),
55
+ sortFns: {
56
+ alphanumeric: sortFn_alphanumeric,
57
+ datetime: sortFn_datetime,
58
+ text: sortFn_text,
59
+ },
60
+ })
61
+ }
62
+
63
+ type Features = ReturnType<typeof createFeatures>
64
+
65
+ export type DataTableColumn<T extends object> = ColumnDef<Features, T>
66
+
67
+ /** Where a column appears in the mobile card, when `meta.mobile` is set. */
68
+ export type DataTableMobileRole =
69
+ | 'title'
70
+ | 'subtitle'
71
+ | 'amount'
72
+ | 'status'
73
+ | 'meta'
74
+
75
+ /** The library reads `meta.align` and `meta.mobile`; both are optional. */
76
+ export interface DataTableColumnMeta {
77
+ align?: 'end'
78
+ mobile?: DataTableMobileRole
79
+ }
80
+
81
+ export interface DataTableProps<T extends { id: string }> {
82
+ title: string
83
+ rows: T[]
84
+ columns: DataTableColumn<T>[]
85
+ actionLabel?: string
86
+ onRowsAction?: (ids: string[]) => void
87
+ /** Pages whose FilterBar owns search hide the table's own. */
88
+ hideSearch?: boolean
89
+ /** Selection checkboxes + bulk action. Default on; off for read tables. */
90
+ selectable?: boolean
91
+ /** Per-row eligibility; ineligible rows cannot be selected. */
92
+ rowSelectable?: (row: T) => boolean
93
+ /** Page-supplied context shown beside Search, e.g. "18 expenses · Sep 2026". */
94
+ caption?: JSX.Element
95
+ /** Skeleton state while the rows are in flight. */
96
+ loading?: boolean
97
+ /** Error state; the table is replaced by the shared DataState. */
98
+ error?: JSX.Element
99
+ onRetry?: () => void
100
+ /** `compact` uses 36px rows instead of 44px. */
101
+ density?: 'comfortable' | 'compact'
102
+ /** Width at which the table becomes cards. Default `sm` (640px). */
103
+ cardBreakpoint?: 'sm' | 'md' | 'lg'
104
+ /** Total or scope shown beside the count in the mobile selection bar. */
105
+ selectionSummary?: (ids: string[]) => JSX.Element
106
+ pageSize?: number
107
+ /** Footer noun: "32 transactions". Defaults to "records". */
108
+ recordsLabel?: string
109
+ emptyTitle?: string
110
+ emptyHint?: string
111
+ /** Empty-state reset for externally filtered pages (beats Clear search). */
112
+ onClearFilters?: () => void
113
+ }
114
+
115
+ /**
116
+ * A collection as a table: search, sort, page, hide columns, select rows.
117
+ *
118
+ * TanStack Table is the engine; this is the kit's composition of it. The surface
119
+ * lives in `@talelk/newui-organisms`: it composes controls and owns the TanStack Table
120
+ * engine. The headless `@talelk/newui-data` package remains independent of rendering.
121
+ */
122
+ export function DataTable<T extends { id: string }>(
123
+ props: DataTableProps<T>,
124
+ ): JSX.Element {
125
+ // Selection exists to feed the bulk action. A read table with no action
126
+ // would render a checkbox that can never do anything.
127
+ const selectable = () =>
128
+ props.selectable !== false && props.onRowsAction !== undefined
129
+ const selection: DataTableColumn<T> = {
130
+ id: 'select',
131
+ enableHiding: false,
132
+ enableSorting: false,
133
+ header: (context) => (
134
+ <Checkbox
135
+ aria-label="Select all rows on this page"
136
+ checked={context.table.getIsAllPageRowsSelected()}
137
+ indeterminate={context.table.getIsSomePageRowsSelected()}
138
+ onCheckedChange={(value) =>
139
+ context.table.toggleAllPageRowsSelected(value)
140
+ }
141
+ />
142
+ ),
143
+ cell: (context) => (
144
+ <Checkbox
145
+ aria-label={`Select ${context.row.original.id}`}
146
+ checked={context.row.getIsSelected()}
147
+ disabled={!context.row.getCanSelect()}
148
+ onCheckedChange={(value) => context.row.toggleSelected(value)}
149
+ />
150
+ ),
151
+ }
152
+ const resolvedColumns = createMemo(() =>
153
+ selectable() ? [selection, ...props.columns] : props.columns,
154
+ )
155
+ const table = createTable<Features, T>({
156
+ features: createFeatures(),
157
+ get data() {
158
+ return props.rows
159
+ },
160
+ get columns() {
161
+ return resolvedColumns()
162
+ },
163
+ getRowId: (row) => row.id,
164
+ get enableRowSelection() {
165
+ return (row: { original: T }) =>
166
+ props.rowSelectable?.(row.original) ?? true
167
+ },
168
+ initialState: {
169
+ // Initial only: later pageSize changes do not repaginate the table.
170
+ pagination: {
171
+ pageIndex: 0,
172
+ pageSize: untrack(() => props.pageSize) ?? 10,
173
+ },
174
+ },
175
+ })
176
+ // Externally filtered pages hand over a new row set per filter change;
177
+ // staying on page 3 of a 6-row result would show an empty page.
178
+ createEffect(
179
+ on(
180
+ () => props.rows,
181
+ () => table.setPageIndex(0),
182
+ { defer: true },
183
+ ),
184
+ )
185
+ const alignEnd = (meta: unknown): boolean =>
186
+ (meta as DataTableColumnMeta | undefined)?.align === 'end'
187
+ const mobileRole = (meta: unknown) =>
188
+ (meta as DataTableColumnMeta | undefined)?.mobile
189
+ const hasMobileCards = createMemo(() =>
190
+ props.columns.some((column) => mobileRole(column.meta)),
191
+ )
192
+ const selected = () =>
193
+ table.getFilteredSelectedRowModel().rows.map((row) => row.original.id)
194
+ const status = () =>
195
+ props.loading
196
+ ? ('loading' as const)
197
+ : props.error
198
+ ? ('error' as const)
199
+ : ('ready' as const)
200
+ const runAction = () => {
201
+ props.onRowsAction?.(selected())
202
+ table.resetRowSelection()
203
+ }
204
+ const cellPad = () =>
205
+ props.density === 'compact' ? 'px-4 py-2' : 'px-4 py-3'
206
+ const cardHiddenClass = () =>
207
+ props.cardBreakpoint === 'lg'
208
+ ? 'lg:hidden'
209
+ : props.cardBreakpoint === 'md'
210
+ ? 'md:hidden'
211
+ : 'sm:hidden'
212
+ const tableHiddenClass = () =>
213
+ props.cardBreakpoint === 'lg'
214
+ ? 'hidden lg:block'
215
+ : props.cardBreakpoint === 'md'
216
+ ? 'hidden md:block'
217
+ : 'hidden sm:block'
218
+ const emptyAction = () => {
219
+ // Do not offer to reset a filter this table does not own. With `hideSearch`
220
+ // the page's FilterBar is the only reset path, so it must provide one.
221
+ if (props.hideSearch && !props.onClearFilters) return undefined
222
+ return (
223
+ <Button
224
+ onClick={() => {
225
+ if (props.onClearFilters) props.onClearFilters()
226
+ else table.resetGlobalFilter()
227
+ }}
228
+ >
229
+ {props.onClearFilters ? 'Reset filters' : 'Clear search'}
230
+ </Button>
231
+ )
232
+ }
233
+ /**
234
+ * The table's own controls. The toolbar is stable at every width: when an
235
+ * external FilterBar owns search and there is no caption, the table title
236
+ * fills the space, so a selection never inserts a row or moves the menu.
237
+ */
238
+ const pageCount = () =>
239
+ Math.max(
240
+ 1,
241
+ Math.ceil(
242
+ table.getFilteredRowModel().rows.length /
243
+ table.atoms.pagination.get().pageSize,
244
+ ),
245
+ )
246
+ const columnLabel = (column: {
247
+ id: string
248
+ columnDef: { header?: unknown }
249
+ }) =>
250
+ typeof column.columnDef.header === 'string'
251
+ ? column.columnDef.header
252
+ : column.id
253
+ const columnMenu = (placement: 'up' | 'down') => (
254
+ <details
255
+ data-slot="data-table-columns"
256
+ class="relative max-sm:hidden"
257
+ open={false}
258
+ >
259
+ <summary class="list-none cursor-pointer rounded-3 border border-outline-gray-2 px-3 py-1.5 text-interface focus-visible:focus-ring [&::-webkit-details-marker]:hidden">
260
+ Columns
261
+ </summary>
262
+ <div
263
+ class={`absolute end-0 z-floating flex min-w-44 flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-elevation-2 p-3 shadow-md ${placement === 'up' ? 'bottom-full mb-2' : 'mt-2'}`}
264
+ >
265
+ <For
266
+ each={table
267
+ .getAllLeafColumns()
268
+ .filter((column) => column.getCanHide())}
269
+ >
270
+ {(column) => (
271
+ <Checkbox
272
+ label={columnLabel(column)}
273
+ checked={column.getIsVisible()}
274
+ onCheckedChange={(value) => column.toggleVisibility(value)}
275
+ />
276
+ )}
277
+ </For>
278
+ </div>
279
+ </details>
280
+ )
281
+ return (
282
+ <section
283
+ class="min-w-0 rounded-card border border-outline-gray-1 bg-surface-elevation-1"
284
+ aria-label={props.title}
285
+ data-slot="data-table"
286
+ >
287
+ <div
288
+ data-slot="data-table-toolbar"
289
+ class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact"
290
+ >
291
+ <Show when={!props.hideSearch}>
292
+ <div class="w-full sm:w-72">
293
+ <TextInput
294
+ type="search"
295
+ aria-label={`Search ${props.title.toLowerCase()}`}
296
+ placeholder={`Search ${props.title.toLowerCase()}…`}
297
+ value={String(table.atoms.globalFilter.get() ?? '')}
298
+ onInput={(event) => {
299
+ table.setGlobalFilter(event.currentTarget.value)
300
+ table.setPageIndex(0)
301
+ }}
302
+ />
303
+ </div>
304
+ </Show>
305
+ <Show when={props.caption}>
306
+ <p class="min-w-0 text-caption text-ink-gray-7">{props.caption}</p>
307
+ </Show>
308
+ <Show when={props.hideSearch && !props.caption}>
309
+ <span class="min-w-0 text-interface font-medium text-ink-gray-8">
310
+ {props.title}
311
+ </span>
312
+ </Show>
313
+ <div class="ms-auto flex flex-wrap items-center gap-2">
314
+ <Show when={props.onRowsAction && selected().length}>
315
+ <span class="text-caption text-ink-gray-7 tabular-nums">
316
+ {props.selectionSummary?.(selected())}
317
+ </span>
318
+ <Button variant="outline" class="max-sm:hidden" onClick={runAction}>
319
+ {props.actionLabel ?? 'Update selected'} ({selected().length})
320
+ </Button>
321
+ </Show>
322
+ {columnMenu('down')}
323
+ </div>
324
+ </div>
325
+ <Show
326
+ when={status() === 'ready'}
327
+ fallback={
328
+ <DataState
329
+ status={status()}
330
+ error={props.error}
331
+ onRetry={props.onRetry}
332
+ rows={6}
333
+ />
334
+ }
335
+ >
336
+ <Show when={hasMobileCards() && table.getRowModel().rows.length > 0}>
337
+ <div
338
+ data-slot="data-table-cards"
339
+ class={`flex flex-col gap-2 p-card-pad-compact ${cardHiddenClass()}`}
340
+ >
341
+ <Show when={selectable()}>
342
+ <div class="flex items-center justify-between gap-2 px-1">
343
+ <Checkbox
344
+ aria-label={`Select all ${props.title} on this page`}
345
+ padded
346
+ class="size-10"
347
+ checked={table.getIsAllPageRowsSelected()}
348
+ indeterminate={table.getIsSomePageRowsSelected()}
349
+ onCheckedChange={(value) =>
350
+ table.toggleAllPageRowsSelected(value)
351
+ }
352
+ />
353
+ <span class="text-caption text-ink-gray-7">
354
+ {selected().length} selected
355
+ </span>
356
+ </div>
357
+ </Show>
358
+ <For each={table.getRowModel().rows}>
359
+ {(row) => {
360
+ const cells = (role: DataTableMobileRole) =>
361
+ row
362
+ .getVisibleCells()
363
+ .filter(
364
+ (cell) => mobileRole(cell.column.columnDef.meta) === role,
365
+ )
366
+ return (
367
+ <div
368
+ class="rounded-4 border border-outline-gray-1 p-3"
369
+ classList={{ 'bg-surface-blue-1': row.getIsSelected() }}
370
+ data-selected={row.getIsSelected() ? '' : undefined}
371
+ >
372
+ <div class="flex items-start gap-2">
373
+ <Show when={selectable()}>
374
+ <Checkbox
375
+ aria-label={`Select ${row.original.id}`}
376
+ padded
377
+ class="size-10"
378
+ checked={row.getIsSelected()}
379
+ disabled={!row.getCanSelect()}
380
+ onCheckedChange={(value) => row.toggleSelected(value)}
381
+ />
382
+ </Show>
383
+ <div class="min-w-0 flex-1">
384
+ <div class="flex items-start justify-between gap-3">
385
+ <div class="min-w-0">
386
+ <For each={cells('title')}>
387
+ {(cell) => (
388
+ <div class="text-interface font-medium [overflow-wrap:anywhere]">
389
+ {flexRender(
390
+ cell.column.columnDef.cell,
391
+ cell.getContext(),
392
+ )}
393
+ </div>
394
+ )}
395
+ </For>
396
+ <For each={cells('subtitle')}>
397
+ {(cell) => (
398
+ <div class="text-caption text-ink-gray-7 [overflow-wrap:anywhere]">
399
+ {flexRender(
400
+ cell.column.columnDef.cell,
401
+ cell.getContext(),
402
+ )}
403
+ </div>
404
+ )}
405
+ </For>
406
+ </div>
407
+ <For each={cells('amount')}>
408
+ {(cell) => (
409
+ <span class="shrink-0 text-interface font-medium tabular-nums">
410
+ {flexRender(
411
+ cell.column.columnDef.cell,
412
+ cell.getContext(),
413
+ )}
414
+ </span>
415
+ )}
416
+ </For>
417
+ </div>
418
+ <div class="mt-1 flex flex-wrap items-center gap-2">
419
+ <For each={cells('status')}>
420
+ {(cell) =>
421
+ flexRender(
422
+ cell.column.columnDef.cell,
423
+ cell.getContext(),
424
+ )
425
+ }
426
+ </For>
427
+ <For each={cells('meta')}>
428
+ {(cell) => (
429
+ <span class="text-caption text-ink-gray-7">
430
+ {flexRender(
431
+ cell.column.columnDef.cell,
432
+ cell.getContext(),
433
+ )}
434
+ </span>
435
+ )}
436
+ </For>
437
+ </div>
438
+ </div>
439
+ </div>
440
+ </div>
441
+ )
442
+ }}
443
+ </For>
444
+ </div>
445
+ </Show>
446
+ <Show when={table.getFilteredRowModel().rows.length > 0}>
447
+ <div class={hasMobileCards() ? tableHiddenClass() : ''}>
448
+ <ScrollTable label={`${props.title} table`} stickyHeader>
449
+ <table class="w-full min-w-[720px] border-collapse text-start">
450
+ <ScrollTableHead>
451
+ <For each={table.getHeaderGroups()}>
452
+ {(group) => (
453
+ <tr>
454
+ <For each={group.headers}>
455
+ {(header) => (
456
+ <th
457
+ scope="col"
458
+ class={
459
+ alignEnd(header.column.columnDef.meta)
460
+ ? `${cellPad()} text-end`
461
+ : `${cellPad()} text-start`
462
+ }
463
+ aria-sort={
464
+ header.column.getIsSorted() === 'asc'
465
+ ? 'ascending'
466
+ : header.column.getIsSorted() === 'desc'
467
+ ? 'descending'
468
+ : undefined
469
+ }
470
+ >
471
+ <Show when={!header.isPlaceholder}>
472
+ <Show
473
+ when={header.column.getCanSort()}
474
+ fallback={flexRender(
475
+ header.column.columnDef.header,
476
+ header.getContext(),
477
+ )}
478
+ >
479
+ <button
480
+ type="button"
481
+ class="inline-flex items-center gap-2 rounded-3 focus-visible:focus-ring"
482
+ onClick={header.column.getToggleSortingHandler()}
483
+ >
484
+ {flexRender(
485
+ header.column.columnDef.header,
486
+ header.getContext(),
487
+ )}
488
+ <span aria-hidden="true">
489
+ <span
490
+ aria-hidden="true"
491
+ class={cn(
492
+ 'inline-block size-4 align-middle',
493
+ header.column.getIsSorted() === 'asc'
494
+ ? 'lucide-arrow-up'
495
+ : header.column.getIsSorted() ===
496
+ 'desc'
497
+ ? 'lucide-arrow-down'
498
+ : 'lucide-arrow-up-down',
499
+ )}
500
+ />
501
+ </span>
502
+ </button>
503
+ </Show>
504
+ </Show>
505
+ </th>
506
+ )}
507
+ </For>
508
+ </tr>
509
+ )}
510
+ </For>
511
+ </ScrollTableHead>
512
+ <tbody>
513
+ <For each={table.getRowModel().rows}>
514
+ {(row) => (
515
+ <tr
516
+ class="border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
517
+ data-selected={row.getIsSelected() ? '' : undefined}
518
+ >
519
+ <For each={row.getVisibleCells()}>
520
+ {(cell) => (
521
+ <td
522
+ class={
523
+ alignEnd(cell.column.columnDef.meta)
524
+ ? `${cellPad()} text-end text-interface`
525
+ : `${cellPad()} text-interface`
526
+ }
527
+ >
528
+ {flexRender(
529
+ cell.column.columnDef.cell,
530
+ cell.getContext(),
531
+ )}
532
+ </td>
533
+ )}
534
+ </For>
535
+ </tr>
536
+ )}
537
+ </For>
538
+ </tbody>
539
+ </table>
540
+ </ScrollTable>
541
+ </div>
542
+ </Show>
543
+ <Show when={table.getFilteredRowModel().rows.length === 0}>
544
+ <DataState
545
+ status="empty"
546
+ title={props.emptyTitle ?? 'No matching records'}
547
+ description={
548
+ props.emptyHint ?? 'Try a different search or clear your filters.'
549
+ }
550
+ action={emptyAction()}
551
+ />
552
+ </Show>
553
+ <Show when={table.getFilteredRowModel().rows.length > 0}>
554
+ <div class="flex flex-wrap items-center justify-between gap-control-gap border-t border-outline-gray-1 p-card-pad-compact">
555
+ <span class="text-caption text-ink-gray-7" role="status">
556
+ <Show when={selectable()}>{selected().length} selected · </Show>
557
+ {table.getFilteredRowModel().rows.length}{' '}
558
+ {props.recordsLabel ?? 'records'}
559
+ </span>
560
+ <div class="flex flex-wrap items-center gap-2">
561
+ <Pagination.Root
562
+ class="max-sm:flex-nowrap"
563
+ page={table.atoms.pagination.get().pageIndex + 1}
564
+ pageSize={table.atoms.pagination.get().pageSize}
565
+ count={table.getFilteredRowModel().rows.length}
566
+ onPageChange={(page) => table.setPageIndex(page - 1)}
567
+ >
568
+ <span class="contents max-sm:hidden">
569
+ <Pagination.Range />
570
+ </span>
571
+ <Show when={pageCount() > 1}>
572
+ <span class="text-caption text-ink-gray-7 tabular-nums sm:hidden">
573
+ Page {table.atoms.pagination.get().pageIndex + 1} of{' '}
574
+ {pageCount()}
575
+ </span>
576
+ <Pagination.PrevTrigger />
577
+ <span class="contents max-sm:hidden">
578
+ <Pagination.Pages />
579
+ </span>
580
+ <Pagination.NextTrigger />
581
+ </Show>
582
+ </Pagination.Root>
583
+ </div>
584
+ </div>
585
+ </Show>
586
+ </Show>
587
+ <Show when={selectable() && selected().length > 0}>
588
+ <div class="h-16 sm:hidden" aria-hidden="true" />
589
+ </Show>
590
+ <div class="sm:hidden">
591
+ <SelectionBar
592
+ count={selected().length}
593
+ placement="sticky"
594
+ summary={
595
+ props.selectionSummary
596
+ ? props.selectionSummary(selected())
597
+ : undefined
598
+ }
599
+ primary={
600
+ props.onRowsAction
601
+ ? {
602
+ label: props.actionLabel ?? 'Update selected',
603
+ onClick: runAction,
604
+ }
605
+ : undefined
606
+ }
607
+ onClear={() => table.resetRowSelection()}
608
+ />
609
+ </div>
610
+ </section>
611
+ )
612
+ }
@@ -0,0 +1 @@
1
+ export * from './DataTable'
@@ -0,0 +1,55 @@
1
+ import { render, screen } from '@solidjs/testing-library'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { DetailHeader } from './DetailHeader'
5
+
6
+ describe('DetailHeader', () => {
7
+ it('renders the trail, the title, the status and the subtitle', () => {
8
+ render(() => (
9
+ <DetailHeader
10
+ breadcrumbs={[{ label: 'Files', route: '/files' }, { label: 'Report' }]}
11
+ title="Q3 report.pdf"
12
+ subtitle="1.2 MB · updated 2h ago"
13
+ status={{ label: 'Shared', theme: 'blue' }}
14
+ />
15
+ ))
16
+
17
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeTruthy()
18
+ expect(
19
+ screen.getByRole('heading', { level: 1, name: 'Q3 report.pdf' }),
20
+ ).toBeTruthy()
21
+ expect(screen.getByText('Shared')).toBeTruthy()
22
+ expect(screen.getByText('1.2 MB · updated 2h ago')).toBeTruthy()
23
+ })
24
+
25
+ it('puts the primary action last and calls the right handler', async () => {
26
+ const onShare = vi.fn()
27
+ const onDownload = vi.fn()
28
+ render(() => (
29
+ <DetailHeader
30
+ title="Report"
31
+ actions={[
32
+ { label: 'Share', onClick: onShare },
33
+ { label: 'Download', onClick: onDownload },
34
+ ]}
35
+ />
36
+ ))
37
+
38
+ const buttons = screen.getAllByRole('button')
39
+ expect(buttons.at(-1)?.textContent).toBe('Download')
40
+
41
+ await userEvent.click(screen.getByRole('button', { name: 'Share' }))
42
+ expect(onShare).toHaveBeenCalled()
43
+ expect(onDownload).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('renders one action as primary with no outline sibling', () => {
47
+ render(() => <DetailHeader title="Report" actions={[{ label: 'Edit' }]} />)
48
+ expect(screen.getAllByRole('button')).toHaveLength(1)
49
+ })
50
+
51
+ it('omits the trail when there is none', () => {
52
+ const { container } = render(() => <DetailHeader title="Report" />)
53
+ expect(container.querySelector('nav')).toBeNull()
54
+ })
55
+ })