@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.
- package/package.json +8 -4
- package/skills/newui-organisms/SKILL.md +91 -0
- package/src/exports.test.ts +3 -0
- package/src/organisms/AppSidebar/AppSidebar.a11y.test.tsx +75 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +36 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +57 -12
- package/src/organisms/AuthCard/AuthCard.tsx +41 -32
- package/src/organisms/CommandPalette/CommandPalette.a11y.test.tsx +58 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +13 -2
- package/src/organisms/DataState/DataState.a11y.test.tsx +59 -0
- package/src/organisms/DataState/DataState.browser.test.tsx +52 -0
- package/src/organisms/DataState/DataState.test.tsx +45 -0
- package/src/organisms/DataState/DataState.tsx +111 -0
- package/src/organisms/DataState/index.ts +6 -0
- package/src/organisms/DataTable/DataTable.a11y.test.tsx +71 -0
- package/src/organisms/DataTable/DataTable.features.test.tsx +191 -0
- package/src/organisms/DataTable/DataTable.test.tsx +73 -1
- package/src/organisms/DataTable/DataTable.tsx +609 -149
- package/src/organisms/DetailHeader/DetailHeader.a11y.test.tsx +53 -0
- package/src/organisms/DetailHeader/DetailHeader.browser.test.tsx +50 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +9 -1
- package/src/organisms/FilterBar/FilterBar.a11y.test.tsx +58 -0
- package/src/organisms/FilterBar/FilterBar.browser.test.tsx +46 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +2 -1
- package/src/organisms/FilterBar/FilterBar.tsx +24 -5
- package/src/organisms/KanbanBoard/KanbanBoard.a11y.test.tsx +75 -0
- package/src/organisms/KanbanBoard/KanbanBoard.browser.test.tsx +84 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +220 -165
- package/src/organisms/MobileNav/MobileNav.a11y.test.tsx +49 -0
- package/src/organisms/MobileNav/MobileNav.tsx +1 -1
- package/src/organisms/PageToolbar/PageToolbar.tsx +63 -14
- package/src/organisms/Section/Section.a11y.test.tsx +49 -0
- package/src/organisms/Section/Section.browser.test.tsx +63 -0
- package/src/organisms/Section/Section.test.tsx +41 -0
- package/src/organisms/Section/Section.tsx +108 -0
- package/src/organisms/Section/index.ts +1 -0
- package/src/organisms/SelectionBar/SelectionBar.a11y.test.tsx +48 -0
- package/src/organisms/SelectionBar/SelectionBar.browser.test.tsx +57 -0
- package/src/organisms/SelectionBar/SelectionBar.test.tsx +35 -0
- package/src/organisms/SelectionBar/SelectionBar.tsx +84 -0
- package/src/organisms/SelectionBar/index.ts +5 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +13 -4
- package/src/organisms/Sidebar/Sidebar.a11y.test.tsx +71 -0
- package/src/organisms/Sidebar/Sidebar.tsx +17 -10
- package/src/organisms/StatGrid/StatGrid.test.tsx +13 -1
- package/src/organisms/StatGrid/StatGrid.tsx +98 -71
- package/src/organisms/VirtualList/VirtualList.a11y.test.tsx +109 -0
- package/src/organisms/VirtualList/VirtualList.tsx +56 -22
- package/src/organisms/index.ts +3 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
252
|
-
</
|
|
253
|
-
</
|
|
254
|
-
|
|
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
|
-
</
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
{
|
|
278
|
-
cell
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
</
|
|
285
|
-
|
|
286
|
-
</
|
|
287
|
-
</
|
|
288
|
-
</
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
{
|
|
294
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
<
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
)
|