@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.
- package/LICENSE +202 -0
- package/README.md +12 -2
- package/package.json +38 -3
- package/src/exports.test.ts +27 -0
- package/src/index.ts +1 -0
- package/src/organisms/AppSidebar/AppSidebar.test.tsx +136 -0
- package/src/organisms/AppSidebar/AppSidebar.tsx +259 -0
- package/src/organisms/AppSidebar/index.ts +1 -0
- package/src/organisms/AuthCard/AuthCard.a11y.test.tsx +36 -0
- package/src/organisms/AuthCard/AuthCard.test.tsx +56 -0
- package/src/organisms/AuthCard/AuthCard.tsx +89 -0
- package/src/organisms/AuthCard/index.ts +1 -0
- package/src/organisms/CommandPalette/CommandPalette.test.tsx +196 -0
- package/src/organisms/CommandPalette/CommandPalette.tsx +468 -0
- package/src/organisms/CommandPalette/index.ts +1 -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.test.tsx +161 -0
- package/src/organisms/DataTable/DataTable.tsx +612 -0
- package/src/organisms/DataTable/index.ts +1 -0
- package/src/organisms/DetailHeader/DetailHeader.test.tsx +55 -0
- package/src/organisms/DetailHeader/DetailHeader.tsx +95 -0
- package/src/organisms/DetailHeader/index.ts +1 -0
- package/src/organisms/FilterBar/FilterBar.test.tsx +152 -0
- package/src/organisms/FilterBar/FilterBar.tsx +251 -0
- package/src/organisms/FilterBar/index.ts +1 -0
- package/src/organisms/KanbanBoard/KanbanBoard.test.tsx +255 -0
- package/src/organisms/KanbanBoard/KanbanBoard.tsx +605 -0
- package/src/organisms/KanbanBoard/drag.test.ts +448 -0
- package/src/organisms/KanbanBoard/drag.ts +407 -0
- package/src/organisms/KanbanBoard/index.ts +17 -0
- package/src/organisms/MobileNav/MobileNav.browser.test.tsx +39 -0
- package/src/organisms/MobileNav/MobileNav.test.tsx +108 -0
- package/src/organisms/MobileNav/MobileNav.tsx +120 -0
- package/src/organisms/MobileNav/index.ts +6 -0
- package/src/organisms/PageToolbar/PageToolbar.a11y.test.tsx +35 -0
- package/src/organisms/PageToolbar/PageToolbar.test.tsx +42 -0
- package/src/organisms/PageToolbar/PageToolbar.tsx +126 -0
- package/src/organisms/PageToolbar/index.ts +1 -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.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.a11y.test.tsx +58 -0
- package/src/organisms/SettingsDialog/SettingsDialog.browser.test.tsx +43 -0
- package/src/organisms/SettingsDialog/SettingsDialog.ssr.test.tsx +27 -0
- package/src/organisms/SettingsDialog/SettingsDialog.test.tsx +202 -0
- package/src/organisms/SettingsDialog/SettingsDialog.tsx +548 -0
- package/src/organisms/SettingsDialog/index.ts +20 -0
- package/src/organisms/Sidebar/Sidebar.browser.test.tsx +52 -0
- package/src/organisms/Sidebar/Sidebar.test.tsx +308 -0
- package/src/organisms/Sidebar/Sidebar.tsx +732 -0
- package/src/organisms/Sidebar/index.ts +22 -0
- package/src/organisms/StatGrid/StatGrid.a11y.test.tsx +40 -0
- package/src/organisms/StatGrid/StatGrid.test.tsx +95 -0
- package/src/organisms/StatGrid/StatGrid.tsx +151 -0
- package/src/organisms/StatGrid/index.ts +1 -0
- package/src/organisms/VirtualList/VirtualList.test.tsx +80 -0
- package/src/organisms/VirtualList/VirtualList.tsx +113 -0
- package/src/organisms/VirtualList/index.ts +1 -0
- 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
|
+
})
|