@talelk/newui-organisms 1.0.0 → 2.1.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 (50) hide show
  1. package/package.json +8 -4
  2. package/skills/newui-organisms/SKILL.md +91 -0
  3. package/src/exports.test.ts +3 -0
  4. package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
  5. package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
  6. package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
  7. package/src/organisms/AuthCard/AuthCard.tsx +41 -32
  8. package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
  9. package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
  10. package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
  11. package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
  12. package/src/organisms/DataState/DataState.test.tsx +45 -0
  13. package/src/organisms/DataState/DataState.tsx +111 -0
  14. package/src/organisms/DataState/index.ts +6 -0
  15. package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
  16. package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
  17. package/src/organisms/DataTable/DataTable.test.tsx +73 -1
  18. package/src/organisms/DataTable/DataTable.tsx +609 -149
  19. package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
  20. package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
  21. package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
  22. package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
  23. package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
  24. package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
  25. package/src/organisms/FilterBar/FilterBar.tsx +24 -5
  26. package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
  27. package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
  28. package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
  29. package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
  30. package/src/organisms/MobileNav/MobileNav.tsx +1 -1
  31. package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
  32. package/src/organisms/Section/Section.a11y.test.tsx +49 -0
  33. package/src/organisms/Section/Section.browser.test.tsx +63 -0
  34. package/src/organisms/Section/Section.test.tsx +41 -0
  35. package/src/organisms/Section/Section.tsx +108 -0
  36. package/src/organisms/Section/index.ts +1 -0
  37. package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
  38. package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
  39. package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
  40. package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
  41. package/src/organisms/SelectionBar/index.ts +5 -0
  42. package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
  43. package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
  44. package/src/organisms/Sidebar/Sidebar.tsx +17 -10
  45. package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
  46. package/src/organisms/StatGrid/StatGrid.tsx +98 -71
  47. package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
  48. package/src/organisms/VirtualList/VirtualList.tsx +56 -22
  49. package/src/organisms/index.ts +3 -0
  50. package/src/organisms/Sidebar/__screenshots__/Sidebar.browser.test.tsx/Sidebar--browser--real-CSS--reserves-its-declared-width-1.png +0 -0
@@ -1,4 +1,5 @@
1
- import { Button, Checkbox, TextInput } from '@talelk/newui-atoms'
1
+ import { Button, Checkbox, cn, TextInput } from '@talelk/newui-atoms'
2
+ import { createControllableSignal } from '@talelk/newui-atoms/internal'
2
3
  import {
3
4
  Pagination,
4
5
  ScrollTable,
@@ -7,6 +8,7 @@ import {
7
8
  import {
8
9
  type ColumnDef,
9
10
  columnFilteringFeature,
11
+ columnPinningFeature,
10
12
  columnVisibilityFeature,
11
13
  createFilteredRowModel,
12
14
  createPaginatedRowModel,
@@ -25,12 +27,15 @@ import {
25
27
  import {
26
28
  createEffect,
27
29
  createMemo,
30
+ createSignal,
28
31
  For,
29
32
  type JSX,
30
33
  on,
31
34
  Show,
32
35
  untrack,
33
36
  } from 'solid-js'
37
+ import { DataState } from '../DataState'
38
+ import { SelectionBar } from '../SelectionBar'
34
39
 
35
40
  /**
36
41
  * Built on the first table, not when the module is evaluated.
@@ -42,6 +47,7 @@ import {
42
47
  function createFeatures() {
43
48
  return tableFeatures({
44
49
  columnFilteringFeature,
50
+ columnPinningFeature,
45
51
  columnVisibilityFeature,
46
52
  globalFilteringFeature,
47
53
  rowPaginationFeature,
@@ -62,6 +68,32 @@ type Features = ReturnType<typeof createFeatures>
62
68
 
63
69
  export type DataTableColumn<T extends object> = ColumnDef<Features, T>
64
70
 
71
+ /** Where a column appears in the mobile card, when `meta.mobile` is set. */
72
+ export type DataTableMobileRole =
73
+ | 'title'
74
+ | 'subtitle'
75
+ | 'amount'
76
+ | 'status'
77
+ | 'meta'
78
+
79
+ /** The library reads `meta.align`, `meta.mobile` and `meta.editable`; all optional. */
80
+ export interface DataTableColumnMeta {
81
+ align?: 'end'
82
+ mobile?: DataTableMobileRole
83
+ /**
84
+ * The cell edits in place when the table is `editable`. `editInput` picks
85
+ * the editor (default `text`).
86
+ */
87
+ editable?: boolean
88
+ editInput?: 'text' | 'number'
89
+ }
90
+
91
+ /** Which column is frozen to each edge of the horizontal scroll. */
92
+ export interface DataTablePinned {
93
+ start?: string
94
+ end?: string
95
+ }
96
+
65
97
  export interface DataTableProps<T extends { id: string }> {
66
98
  title: string
67
99
  rows: T[]
@@ -72,6 +104,21 @@ export interface DataTableProps<T extends { id: string }> {
72
104
  hideSearch?: boolean
73
105
  /** Selection checkboxes + bulk action. Default on; off for read tables. */
74
106
  selectable?: boolean
107
+ /** Per-row eligibility; ineligible rows cannot be selected. */
108
+ rowSelectable?: (row: T) => boolean
109
+ /** Page-supplied context shown beside Search, e.g. "18 expenses · Sep 2026". */
110
+ caption?: JSX.Element
111
+ /** Skeleton state while the rows are in flight. */
112
+ loading?: boolean
113
+ /** Error state; the table is replaced by the shared DataState. */
114
+ error?: JSX.Element
115
+ onRetry?: () => void
116
+ /** `compact` uses 36px rows instead of 44px. */
117
+ density?: 'comfortable' | 'compact'
118
+ /** Width at which the table becomes cards. Default `sm` (640px). */
119
+ cardBreakpoint?: 'sm' | 'md' | 'lg'
120
+ /** Total or scope shown beside the count in the mobile selection bar. */
121
+ selectionSummary?: (ids: string[]) => JSX.Element
75
122
  pageSize?: number
76
123
  /** Footer noun: "32 transactions". Defaults to "records". */
77
124
  recordsLabel?: string
@@ -79,10 +126,97 @@ export interface DataTableProps<T extends { id: string }> {
79
126
  emptyHint?: string
80
127
  /** Empty-state reset for externally filtered pages (beats Clear search). */
81
128
  onClearFilters?: () => void
129
+ /**
130
+ * Freeze one column to the start edge of the horizontal scroll — the
131
+ * identifier that must stay visible while the rest move. Controlled:
132
+ * changes go through `onPinnedColumnsChange`.
133
+ */
134
+ pinnedStart?: string
135
+ /** Freeze one column to the end edge — typically the row actions. */
136
+ pinnedEnd?: string
137
+ defaultPinnedStart?: string
138
+ defaultPinnedEnd?: string
139
+ onPinnedColumnsChange?: (pinned: DataTablePinned) => void
140
+ /**
141
+ * Turn on inline editing: columns whose `meta.editable` is set render a
142
+ * click-to-edit cell. Values are app-owned — edits arrive through
143
+ * `onCellChange`, and the table shows whatever the next `rows` carry.
144
+ */
145
+ editable?: boolean
146
+ onCellChange?: (id: string, columnId: string, value: string) => void
147
+ }
148
+
149
+ /**
150
+ * A click-to-edit cell. Enter or blur commits; Escape cancels. The value is
151
+ * the row's raw field, not the formatted render.
152
+ */
153
+ function EditableCell(props: {
154
+ id: string
155
+ columnId: string
156
+ value: string
157
+ label: string
158
+ type: 'text' | 'number'
159
+ alignEnd: boolean
160
+ onCommit: (id: string, columnId: string, value: string) => void
161
+ }): JSX.Element {
162
+ const [editing, setEditing] = createSignal(false)
163
+ // Seeded at open time (the trigger click), never read while closed.
164
+ const [draft, setDraft] = createSignal('')
165
+ const commit = (original: string) => {
166
+ setEditing(false)
167
+ const value = draft().trim()
168
+ if (value !== original) props.onCommit(props.id, props.columnId, value)
169
+ }
170
+ return (
171
+ <Show
172
+ when={editing()}
173
+ fallback={
174
+ <button
175
+ type="button"
176
+ data-slot="data-table-edit-trigger"
177
+ // The name leads with the visible text: a speech user saying what
178
+ // they see must still land on this control.
179
+ aria-label={`${props.value || '—'} — edit ${props.label} of ${props.id}`}
180
+ class={cn(
181
+ '-mx-1 w-full cursor-text rounded-3 px-1 py-0.5 text-start hover:bg-surface-gray-2 focus-visible:focus-ring',
182
+ props.alignEnd && 'text-end',
183
+ )}
184
+ onClick={() => {
185
+ setDraft(props.value)
186
+ setEditing(true)
187
+ }}
188
+ >
189
+ <Show
190
+ when={props.value !== ''}
191
+ fallback={<span class="text-ink-gray-5">—</span>}
192
+ >
193
+ {props.value}
194
+ </Show>
195
+ </button>
196
+ }
197
+ >
198
+ <TextInput
199
+ aria-label={`Edit ${props.label} of ${props.id}`}
200
+ // The editor opens on the user's click; focus belongs there.
201
+ ref={(el) => queueMicrotask(() => el.focus())}
202
+ type={props.type}
203
+ value={draft()}
204
+ size="xs"
205
+ onInput={(event) => setDraft(event.currentTarget.value)}
206
+ onBlur={() => commit(props.value)}
207
+ onKeyDown={(event) => {
208
+ if (event.key === 'Enter') commit(props.value)
209
+ if (event.key === 'Escape') setEditing(false)
210
+ }}
211
+ class="min-w-0"
212
+ />
213
+ </Show>
214
+ )
82
215
  }
83
216
 
84
217
  /**
85
- * A collection as a table: search, sort, page, hide columns, select rows.
218
+ * A collection as a table: search, sort, page, hide columns, select rows,
219
+ * pin one column to each scroll edge, edit cells in place.
86
220
  *
87
221
  * TanStack Table is the engine; this is the kit's composition of it. The surface
88
222
  * lives in `@talelk/newui-organisms`: it composes controls and owns the TanStack Table
@@ -91,7 +225,10 @@ export interface DataTableProps<T extends { id: string }> {
91
225
  export function DataTable<T extends { id: string }>(
92
226
  props: DataTableProps<T>,
93
227
  ): JSX.Element {
94
- const selectable = () => props.selectable !== false
228
+ // Selection exists to feed the bulk action. A read table with no action
229
+ // would render a checkbox that can never do anything.
230
+ const selectable = () =>
231
+ props.selectable !== false && props.onRowsAction !== undefined
95
232
  const selection: DataTableColumn<T> = {
96
233
  id: 'select',
97
234
  enableHiding: false,
@@ -110,6 +247,7 @@ export function DataTable<T extends { id: string }>(
110
247
  <Checkbox
111
248
  aria-label={`Select ${context.row.original.id}`}
112
249
  checked={context.row.getIsSelected()}
250
+ disabled={!context.row.getCanSelect()}
113
251
  onCheckedChange={(value) => context.row.toggleSelected(value)}
114
252
  />
115
253
  ),
@@ -117,6 +255,38 @@ export function DataTable<T extends { id: string }>(
117
255
  const resolvedColumns = createMemo(() =>
118
256
  selectable() ? [selection, ...props.columns] : props.columns,
119
257
  )
258
+ const [pinned] = createControllableSignal<DataTablePinned>({
259
+ value: () => ({
260
+ start: props.pinnedStart,
261
+ end: props.pinnedEnd,
262
+ }),
263
+ defaultValue: {
264
+ start: props.defaultPinnedStart,
265
+ end: props.defaultPinnedEnd,
266
+ },
267
+ onChange: (next) => props.onPinnedColumnsChange?.(next),
268
+ })
269
+ const editable = () =>
270
+ props.editable === true && props.onCellChange !== undefined
271
+ const editMeta = (meta: unknown) => {
272
+ const columnMeta = meta as DataTableColumnMeta | undefined
273
+ return columnMeta?.editable === true
274
+ ? { input: columnMeta.editInput ?? 'text' }
275
+ : undefined
276
+ }
277
+ /** Which edge (if any) a column id is frozen to. */
278
+ const pinOf = (columnId: string): 'start' | 'end' | undefined => {
279
+ if (pinned().start === columnId) return 'start'
280
+ if (pinned().end === columnId) return 'end'
281
+ return undefined
282
+ }
283
+ /** Sticky cell chrome: opaque card surface over the scrolling columns. */
284
+ const pinClass = (edge: 'start' | 'end' | undefined) =>
285
+ edge === 'start'
286
+ ? 'sticky start-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-e border-outline-gray-1'
287
+ : edge === 'end'
288
+ ? 'sticky end-0 z-raised bg-surface-elevation-1 group-hover/row:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:group-hover/row:bg-surface-blue-2 border-s border-outline-gray-1'
289
+ : ''
120
290
  const table = createTable<Features, T>({
121
291
  features: createFeatures(),
122
292
  get data() {
@@ -125,7 +295,20 @@ export function DataTable<T extends { id: string }>(
125
295
  get columns() {
126
296
  return resolvedColumns()
127
297
  },
298
+ get state() {
299
+ const pinnedValue = pinned()
300
+ return {
301
+ columnPinning: {
302
+ start: pinnedValue.start ? [pinnedValue.start] : [],
303
+ end: pinnedValue.end ? [pinnedValue.end] : [],
304
+ },
305
+ }
306
+ },
128
307
  getRowId: (row) => row.id,
308
+ get enableRowSelection() {
309
+ return (row: { original: T }) =>
310
+ props.rowSelectable?.(row.original) ?? true
311
+ },
129
312
  initialState: {
130
313
  // Initial only: later pageSize changes do not repaginate the table.
131
314
  pagination: {
@@ -144,16 +327,111 @@ export function DataTable<T extends { id: string }>(
144
327
  ),
145
328
  )
146
329
  const alignEnd = (meta: unknown): boolean =>
147
- (meta as { align?: string } | undefined)?.align === 'end'
330
+ (meta as DataTableColumnMeta | undefined)?.align === 'end'
331
+ const mobileRole = (meta: unknown) =>
332
+ (meta as DataTableColumnMeta | undefined)?.mobile
333
+ const hasMobileCards = createMemo(() =>
334
+ props.columns.some((column) => mobileRole(column.meta)),
335
+ )
148
336
  const selected = () =>
149
337
  table.getFilteredSelectedRowModel().rows.map((row) => row.original.id)
338
+ const status = () =>
339
+ props.loading
340
+ ? ('loading' as const)
341
+ : props.error
342
+ ? ('error' as const)
343
+ : ('ready' as const)
344
+ const runAction = () => {
345
+ props.onRowsAction?.(selected())
346
+ table.resetRowSelection()
347
+ }
348
+ const cellPad = () =>
349
+ props.density === 'compact' ? 'px-4 py-2' : 'px-4 py-3'
350
+ const cardHiddenClass = () =>
351
+ props.cardBreakpoint === 'lg'
352
+ ? 'lg:hidden'
353
+ : props.cardBreakpoint === 'md'
354
+ ? 'md:hidden'
355
+ : 'sm:hidden'
356
+ const tableHiddenClass = () =>
357
+ props.cardBreakpoint === 'lg'
358
+ ? 'hidden lg:block'
359
+ : props.cardBreakpoint === 'md'
360
+ ? 'hidden md:block'
361
+ : 'hidden sm:block'
362
+ const emptyAction = () => {
363
+ // Do not offer to reset a filter this table does not own. With `hideSearch`
364
+ // the page's FilterBar is the only reset path, so it must provide one.
365
+ if (props.hideSearch && !props.onClearFilters) return undefined
366
+ return (
367
+ <Button
368
+ onClick={() => {
369
+ if (props.onClearFilters) props.onClearFilters()
370
+ else table.resetGlobalFilter()
371
+ }}
372
+ >
373
+ {props.onClearFilters ? 'Reset filters' : 'Clear search'}
374
+ </Button>
375
+ )
376
+ }
377
+ /**
378
+ * The table's own controls. The toolbar is stable at every width: when an
379
+ * external FilterBar owns search and there is no caption, the table title
380
+ * fills the space, so a selection never inserts a row or moves the menu.
381
+ */
382
+ const pageCount = () =>
383
+ Math.max(
384
+ 1,
385
+ Math.ceil(
386
+ table.getFilteredRowModel().rows.length /
387
+ table.atoms.pagination.get().pageSize,
388
+ ),
389
+ )
390
+ const columnLabel = (column: {
391
+ id: string
392
+ columnDef: { header?: unknown }
393
+ }) =>
394
+ typeof column.columnDef.header === 'string'
395
+ ? column.columnDef.header
396
+ : column.id
397
+ const columnMenu = (placement: 'up' | 'down') => (
398
+ <details
399
+ data-slot="data-table-columns"
400
+ class="relative max-sm:hidden"
401
+ open={false}
402
+ >
403
+ <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">
404
+ Columns
405
+ </summary>
406
+ <div
407
+ 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'}`}
408
+ >
409
+ <For
410
+ each={table
411
+ .getAllLeafColumns()
412
+ .filter((column) => column.getCanHide())}
413
+ >
414
+ {(column) => (
415
+ <Checkbox
416
+ label={columnLabel(column)}
417
+ checked={column.getIsVisible()}
418
+ onCheckedChange={(value) => column.toggleVisibility(value)}
419
+ />
420
+ )}
421
+ </For>
422
+ </div>
423
+ </details>
424
+ )
150
425
  return (
151
426
  <section
152
427
  class="min-w-0 rounded-card border border-outline-gray-1 bg-surface-elevation-1"
153
428
  aria-label={props.title}
154
429
  data-slot="data-table"
155
430
  >
156
- <div class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact">
431
+ <div
432
+ data-slot="data-table-toolbar"
433
+ class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact"
434
+ >
157
435
  <Show when={!props.hideSearch}>
158
436
  <div class="w-full sm:w-72">
159
437
  <TextInput
@@ -168,166 +446,348 @@ export function DataTable<T extends { id: string }>(
168
446
  />
169
447
  </div>
170
448
  </Show>
449
+ <Show when={props.caption}>
450
+ <p class="min-w-0 text-caption text-ink-gray-7">{props.caption}</p>
451
+ </Show>
452
+ <Show when={props.hideSearch && !props.caption}>
453
+ <span class="min-w-0 text-interface font-medium text-ink-gray-8">
454
+ {props.title}
455
+ </span>
456
+ </Show>
171
457
  <div class="ms-auto flex flex-wrap items-center gap-2">
172
458
  <Show when={props.onRowsAction && selected().length}>
173
- <Button
174
- variant="outline"
175
- onClick={() => {
176
- props.onRowsAction?.(selected())
177
- table.resetRowSelection()
178
- }}
179
- >
459
+ <span class="text-caption text-ink-gray-7 tabular-nums">
460
+ {props.selectionSummary?.(selected())}
461
+ </span>
462
+ <Button variant="outline" class="max-sm:hidden" onClick={runAction}>
180
463
  {props.actionLabel ?? 'Update selected'} ({selected().length})
181
464
  </Button>
182
465
  </Show>
183
- <details class="relative">
184
- <summary class="cursor-pointer rounded-3 border border-outline-gray-2 px-3 py-1.5 text-interface focus-visible:focus-ring">
185
- Columns
186
- </summary>
187
- <div class="absolute end-0 z-floating mt-2 flex min-w-44 flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-elevation-2 p-3 shadow-md">
188
- <For
189
- each={table
190
- .getAllLeafColumns()
191
- .filter((column) => column.getCanHide())}
192
- >
193
- {(column) => (
194
- <Checkbox
195
- label={column.id}
196
- checked={column.getIsVisible()}
197
- onCheckedChange={(value) => column.toggleVisibility(value)}
198
- />
199
- )}
200
- </For>
201
- </div>
202
- </details>
466
+ {columnMenu('down')}
203
467
  </div>
204
468
  </div>
205
- <ScrollTable label={`${props.title} table`}>
206
- <table class="w-full min-w-[720px] border-collapse text-start">
207
- <ScrollTableHead>
208
- <For each={table.getHeaderGroups()}>
209
- {(group) => (
210
- <tr>
211
- <For each={group.headers}>
212
- {(header) => (
213
- <th
214
- scope="col"
215
- class={
216
- alignEnd(header.column.columnDef.meta)
217
- ? 'px-4 py-3 text-end'
218
- : 'px-4 py-3 text-start'
219
- }
220
- aria-sort={
221
- header.column.getIsSorted() === 'asc'
222
- ? 'ascending'
223
- : header.column.getIsSorted() === 'desc'
224
- ? 'descending'
225
- : undefined
226
- }
227
- >
228
- <Show when={!header.isPlaceholder}>
229
- <Show
230
- when={header.column.getCanSort()}
231
- fallback={flexRender(
232
- header.column.columnDef.header,
233
- header.getContext(),
234
- )}
235
- >
236
- <button
237
- type="button"
238
- class="inline-flex items-center gap-2 rounded-3 focus-visible:focus-ring"
239
- onClick={header.column.getToggleSortingHandler()}
240
- >
241
- {flexRender(
242
- header.column.columnDef.header,
243
- header.getContext(),
469
+ <Show
470
+ when={status() === 'ready'}
471
+ fallback={
472
+ <DataState
473
+ status={status()}
474
+ error={props.error}
475
+ onRetry={props.onRetry}
476
+ rows={6}
477
+ />
478
+ }
479
+ >
480
+ <Show when={hasMobileCards() && table.getRowModel().rows.length > 0}>
481
+ <div
482
+ data-slot="data-table-cards"
483
+ class={`flex flex-col gap-2 p-card-pad-compact ${cardHiddenClass()}`}
484
+ >
485
+ <Show when={selectable()}>
486
+ <div class="flex items-center justify-between gap-2 px-1">
487
+ <Checkbox
488
+ aria-label={`Select all ${props.title} on this page`}
489
+ padded
490
+ class="size-10"
491
+ checked={table.getIsAllPageRowsSelected()}
492
+ indeterminate={table.getIsSomePageRowsSelected()}
493
+ onCheckedChange={(value) =>
494
+ table.toggleAllPageRowsSelected(value)
495
+ }
496
+ />
497
+ <span class="text-caption text-ink-gray-7">
498
+ {selected().length} selected
499
+ </span>
500
+ </div>
501
+ </Show>
502
+ <For each={table.getRowModel().rows}>
503
+ {(row) => {
504
+ const cells = (role: DataTableMobileRole) =>
505
+ row
506
+ .getVisibleCells()
507
+ .filter(
508
+ (cell) => mobileRole(cell.column.columnDef.meta) === role,
509
+ )
510
+ return (
511
+ <div
512
+ class="rounded-4 border border-outline-gray-1 p-3"
513
+ classList={{ 'bg-surface-blue-1': row.getIsSelected() }}
514
+ data-selected={row.getIsSelected() ? '' : undefined}
515
+ >
516
+ <div class="flex items-start gap-2">
517
+ <Show when={selectable()}>
518
+ <Checkbox
519
+ aria-label={`Select ${row.original.id}`}
520
+ padded
521
+ class="size-10"
522
+ checked={row.getIsSelected()}
523
+ disabled={!row.getCanSelect()}
524
+ onCheckedChange={(value) => row.toggleSelected(value)}
525
+ />
526
+ </Show>
527
+ <div class="min-w-0 flex-1">
528
+ <div class="flex items-start justify-between gap-3">
529
+ <div class="min-w-0">
530
+ <For each={cells('title')}>
531
+ {(cell) => (
532
+ <div class="text-interface font-medium [overflow-wrap:anywhere]">
533
+ {flexRender(
534
+ cell.column.columnDef.cell,
535
+ cell.getContext(),
536
+ )}
537
+ </div>
538
+ )}
539
+ </For>
540
+ <For each={cells('subtitle')}>
541
+ {(cell) => (
542
+ <div class="text-caption text-ink-gray-7 [overflow-wrap:anywhere]">
543
+ {flexRender(
544
+ cell.column.columnDef.cell,
545
+ cell.getContext(),
546
+ )}
547
+ </div>
244
548
  )}
245
- <span aria-hidden="true">
246
- <span
247
- aria-hidden="true"
248
- class="lucide-arrow-up-down inline-block size-4 align-middle"
249
- />
549
+ </For>
550
+ </div>
551
+ <For each={cells('amount')}>
552
+ {(cell) => (
553
+ <span class="shrink-0 text-interface font-medium tabular-nums">
554
+ {flexRender(
555
+ cell.column.columnDef.cell,
556
+ cell.getContext(),
557
+ )}
250
558
  </span>
251
- </button>
252
- </Show>
253
- </Show>
254
- </th>
559
+ )}
560
+ </For>
561
+ </div>
562
+ <div class="mt-1 flex flex-wrap items-center gap-2">
563
+ <For each={cells('status')}>
564
+ {(cell) =>
565
+ flexRender(
566
+ cell.column.columnDef.cell,
567
+ cell.getContext(),
568
+ )
569
+ }
570
+ </For>
571
+ <For each={cells('meta')}>
572
+ {(cell) => (
573
+ <span class="text-caption text-ink-gray-7">
574
+ {flexRender(
575
+ cell.column.columnDef.cell,
576
+ cell.getContext(),
577
+ )}
578
+ </span>
579
+ )}
580
+ </For>
581
+ </div>
582
+ </div>
583
+ </div>
584
+ </div>
585
+ )
586
+ }}
587
+ </For>
588
+ </div>
589
+ </Show>
590
+ <Show when={table.getFilteredRowModel().rows.length > 0}>
591
+ <div class={hasMobileCards() ? tableHiddenClass() : ''}>
592
+ <ScrollTable label={`${props.title} table`} stickyHeader>
593
+ <table class="w-full min-w-[720px] border-collapse text-start">
594
+ <ScrollTableHead>
595
+ <For each={table.getHeaderGroups()}>
596
+ {(group) => (
597
+ <tr>
598
+ <For each={group.headers}>
599
+ {(header) => (
600
+ <th
601
+ scope="col"
602
+ class={cn(
603
+ alignEnd(header.column.columnDef.meta)
604
+ ? `${cellPad()} text-end`
605
+ : `${cellPad()} text-start`,
606
+ pinClass(pinOf(header.column.id)),
607
+ )}
608
+ aria-sort={
609
+ header.column.getIsSorted() === 'asc'
610
+ ? 'ascending'
611
+ : header.column.getIsSorted() === 'desc'
612
+ ? 'descending'
613
+ : undefined
614
+ }
615
+ >
616
+ <Show when={!header.isPlaceholder}>
617
+ <Show
618
+ when={header.column.getCanSort()}
619
+ fallback={flexRender(
620
+ header.column.columnDef.header,
621
+ header.getContext(),
622
+ )}
623
+ >
624
+ <button
625
+ type="button"
626
+ class="inline-flex items-center gap-2 rounded-3 focus-visible:focus-ring"
627
+ onClick={header.column.getToggleSortingHandler()}
628
+ >
629
+ {flexRender(
630
+ header.column.columnDef.header,
631
+ header.getContext(),
632
+ )}
633
+ <span aria-hidden="true">
634
+ <span
635
+ aria-hidden="true"
636
+ class={cn(
637
+ 'inline-block size-4 align-middle',
638
+ header.column.getIsSorted() === 'asc'
639
+ ? 'lucide-arrow-up'
640
+ : header.column.getIsSorted() ===
641
+ 'desc'
642
+ ? 'lucide-arrow-down'
643
+ : 'lucide-arrow-up-down',
644
+ )}
645
+ />
646
+ </span>
647
+ </button>
648
+ </Show>
649
+ </Show>
650
+ </th>
651
+ )}
652
+ </For>
653
+ </tr>
255
654
  )}
256
655
  </For>
257
- </tr>
258
- )}
259
- </For>
260
- </ScrollTableHead>
261
- <tbody>
262
- <For each={table.getRowModel().rows}>
263
- {(row) => (
264
- <tr
265
- class="border-b border-outline-gray-1 hover:bg-surface-gray-1"
266
- data-selected={row.getIsSelected() ? '' : undefined}
267
- >
268
- <For each={row.getVisibleCells()}>
269
- {(cell) => (
270
- <td
271
- class={
272
- alignEnd(cell.column.columnDef.meta)
273
- ? 'px-4 py-3 text-end text-interface'
274
- : 'px-4 py-3 text-interface'
275
- }
656
+ </ScrollTableHead>
657
+ <tbody>
658
+ <For each={table.getRowModel().rows}>
659
+ {(row) => (
660
+ <tr
661
+ class="group/row border-b border-outline-gray-1 hover:bg-surface-gray-1 data-[selected]:bg-surface-blue-1 data-[selected]:hover:bg-surface-blue-2"
662
+ data-selected={row.getIsSelected() ? '' : undefined}
276
663
  >
277
- {flexRender(
278
- cell.column.columnDef.cell,
279
- cell.getContext(),
280
- )}
281
- </td>
664
+ <For each={row.getVisibleCells()}>
665
+ {(cell) => {
666
+ const edge = pinOf(cell.column.id)
667
+ const editingMeta = editable()
668
+ ? editMeta(cell.column.columnDef.meta)
669
+ : undefined
670
+ const raw = (
671
+ row.original as Record<string, unknown>
672
+ )[cell.column.id]
673
+ return (
674
+ <td
675
+ class={cn(
676
+ alignEnd(cell.column.columnDef.meta)
677
+ ? `${cellPad()} text-end text-interface`
678
+ : `${cellPad()} text-interface`,
679
+ pinClass(edge),
680
+ )}
681
+ data-selected={
682
+ row.getIsSelected() ? '' : undefined
683
+ }
684
+ >
685
+ <Show
686
+ when={editingMeta}
687
+ fallback={flexRender(
688
+ cell.column.columnDef.cell,
689
+ cell.getContext(),
690
+ )}
691
+ >
692
+ <EditableCell
693
+ id={row.original.id}
694
+ columnId={cell.column.id}
695
+ value={raw == null ? '' : String(raw)}
696
+ label={columnLabel(
697
+ cell.column as unknown as {
698
+ id: string
699
+ columnDef: { header?: unknown }
700
+ },
701
+ )}
702
+ type={
703
+ editingMeta?.input === 'number'
704
+ ? 'number'
705
+ : 'text'
706
+ }
707
+ alignEnd={alignEnd(
708
+ cell.column.columnDef.meta,
709
+ )}
710
+ onCommit={props.onCellChange ?? (() => {})}
711
+ />
712
+ </Show>
713
+ </td>
714
+ )
715
+ }}
716
+ </For>
717
+ </tr>
282
718
  )}
283
719
  </For>
284
- </tr>
285
- )}
286
- </For>
287
- </tbody>
288
- </table>
289
- </ScrollTable>
290
- <Show when={table.getFilteredRowModel().rows.length === 0}>
291
- <div class="p-10 text-center">
292
- <h3 class="font-semibold">
293
- {props.emptyTitle ?? 'No matching records'}
294
- </h3>
295
- <p class="mt-1 text-ink-gray-7">
296
- {props.emptyHint ?? 'Try a different search or clear your filters.'}
297
- </p>
298
- <Show
299
- when={props.onClearFilters}
300
- fallback={
301
- <Button class="mt-3" onClick={() => table.resetGlobalFilter()}>
302
- Clear search
303
- </Button>
720
+ </tbody>
721
+ </table>
722
+ </ScrollTable>
723
+ </div>
724
+ </Show>
725
+ <Show when={table.getFilteredRowModel().rows.length === 0}>
726
+ <DataState
727
+ status="empty"
728
+ title={props.emptyTitle ?? 'No matching records'}
729
+ description={
730
+ props.emptyHint ?? 'Try a different search or clear your filters.'
304
731
  }
305
- >
306
- {(clear) => (
307
- <Button class="mt-3" onClick={() => clear()()}>
308
- Reset filters
309
- </Button>
310
- )}
311
- </Show>
312
- </div>
732
+ action={emptyAction()}
733
+ />
734
+ </Show>
735
+ <Show when={table.getFilteredRowModel().rows.length > 0}>
736
+ <div class="flex flex-wrap items-center justify-between gap-control-gap border-t border-outline-gray-1 p-card-pad-compact">
737
+ <span class="text-caption text-ink-gray-7" role="status">
738
+ <Show when={selectable()}>{selected().length} selected · </Show>
739
+ {table.getFilteredRowModel().rows.length}{' '}
740
+ {props.recordsLabel ?? 'records'}
741
+ </span>
742
+ <div class="flex flex-wrap items-center gap-2">
743
+ <Pagination.Root
744
+ class="max-sm:flex-nowrap"
745
+ page={table.atoms.pagination.get().pageIndex + 1}
746
+ pageSize={table.atoms.pagination.get().pageSize}
747
+ count={table.getFilteredRowModel().rows.length}
748
+ onPageChange={(page) => table.setPageIndex(page - 1)}
749
+ >
750
+ <span class="contents max-sm:hidden">
751
+ <Pagination.Range />
752
+ </span>
753
+ <Show when={pageCount() > 1}>
754
+ <span class="text-caption text-ink-gray-7 tabular-nums sm:hidden">
755
+ Page {table.atoms.pagination.get().pageIndex + 1} of{' '}
756
+ {pageCount()}
757
+ </span>
758
+ <Pagination.PrevTrigger />
759
+ <span class="contents max-sm:hidden">
760
+ <Pagination.Pages />
761
+ </span>
762
+ <Pagination.NextTrigger />
763
+ </Show>
764
+ </Pagination.Root>
765
+ </div>
766
+ </div>
767
+ </Show>
313
768
  </Show>
314
- <div class="flex flex-wrap items-center justify-between gap-control-gap p-card-pad-compact">
315
- <span class="text-caption text-ink-gray-7" role="status">
316
- <Show when={selectable()}>{selected().length} selected · </Show>
317
- {table.getFilteredRowModel().rows.length}{' '}
318
- {props.recordsLabel ?? 'records'}
319
- </span>
320
- <Pagination.Root
321
- page={table.atoms.pagination.get().pageIndex + 1}
322
- pageSize={table.atoms.pagination.get().pageSize}
323
- count={table.getFilteredRowModel().rows.length}
324
- onPageChange={(page) => table.setPageIndex(page - 1)}
325
- >
326
- <Pagination.Range />
327
- <Pagination.PrevTrigger />
328
- <Pagination.Pages />
329
- <Pagination.NextTrigger />
330
- </Pagination.Root>
769
+ <Show when={selectable() && selected().length > 0}>
770
+ <div class="h-16 sm:hidden" aria-hidden="true" />
771
+ </Show>
772
+ <div class="sm:hidden">
773
+ <SelectionBar
774
+ count={selected().length}
775
+ placement="sticky"
776
+ summary={
777
+ props.selectionSummary
778
+ ? props.selectionSummary(selected())
779
+ : undefined
780
+ }
781
+ primary={
782
+ props.onRowsAction
783
+ ? {
784
+ label: props.actionLabel ?? 'Update selected',
785
+ onClick: runAction,
786
+ }
787
+ : undefined
788
+ }
789
+ onClear={() => table.resetRowSelection()}
790
+ />
331
791
  </div>
332
792
  </section>
333
793
  )