@duro-app/ui 0.41.0 → 0.42.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/dist/components/VirtualTable/VirtualTable.d.ts +57 -0
- package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -0
- package/dist/components/VirtualTable/VirtualTable.meta.d.ts +3 -0
- package/dist/components/VirtualTable/VirtualTable.meta.d.ts.map +1 -0
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts +14 -0
- package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -0
- package/dist/components/VirtualTable/styles.css.d.ts +128 -0
- package/dist/components/VirtualTable/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2197 -1956
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/VirtualTable/VirtualTable.meta.ts +43 -0
- package/src/components/VirtualTable/VirtualTable.stories.tsx +89 -0
- package/src/components/VirtualTable/VirtualTable.tsx +275 -0
- package/src/components/VirtualTable/styles.css.ts +135 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@
|
|
57
|
+
"@tanstack/react-virtual": "^3.14.6",
|
|
58
|
+
"@duro-app/tokens": "^0.42.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
'Sortable data table that windows its rows above a threshold (default 150) with @tanstack/react-virtual, shows a floating position indicator, and reports the visible page so the caller can mirror it in the URL. Below the threshold it renders every row in one scroll (no pagination). URL-decoupled: pass initialPage + onVisiblePageChange. Web-only.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Long lists/tables (hundreds to tens of thousands of rows) held client-side',
|
|
8
|
+
'When you want a scroll position reflected in the URL + a "rows X–Y of Z" indicator',
|
|
9
|
+
'Admin tables that may grow — small today renders all rows, large windows automatically',
|
|
10
|
+
],
|
|
11
|
+
whenNotToUse: [
|
|
12
|
+
'Small static tables where semantic markup matters — use Table / Table.FromTanstack',
|
|
13
|
+
'Server-paginated data (this windows an already-loaded array, it does not fetch on scroll)',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: ['data', 'columns'],
|
|
17
|
+
optional: [
|
|
18
|
+
'sorting',
|
|
19
|
+
'onSortingChange',
|
|
20
|
+
'initialPage',
|
|
21
|
+
'onVisiblePageChange',
|
|
22
|
+
'rangeLabel',
|
|
23
|
+
'onRowClick',
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
relatedTo: [
|
|
27
|
+
{
|
|
28
|
+
component: 'Table',
|
|
29
|
+
relationship: 'Table is semantic + paginated; VirtualTable windows large lists',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
example: `<VirtualTable
|
|
33
|
+
data={rows}
|
|
34
|
+
columns={columns}
|
|
35
|
+
sorting={sorting}
|
|
36
|
+
onSortingChange={setSorting}
|
|
37
|
+
initialPage={Number(params.get('page')) || 1}
|
|
38
|
+
onVisiblePageChange={({page}) =>
|
|
39
|
+
setParams((p) => { const n = new URLSearchParams(p); page > 1 ? n.set('page', String(page)) : n.delete('page'); return n }, {replace: true, preventScrollReset: true})}
|
|
40
|
+
rangeLabel={({from, to, total}) => t('table.range', {from, to, total})}
|
|
41
|
+
onRowClick={(row) => navigate(\`/things/\${row.id}\`)}
|
|
42
|
+
/>`,
|
|
43
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
|
+
import {createColumnHelper} from '@tanstack/react-table'
|
|
4
|
+
import {VirtualTable} from './VirtualTable'
|
|
5
|
+
|
|
6
|
+
interface Item {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
value: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const col = createColumnHelper<Item>()
|
|
13
|
+
const columns = [
|
|
14
|
+
col.accessor('name', {header: 'Name', enableSorting: true}),
|
|
15
|
+
col.accessor('value', {header: 'Value', enableSorting: true}),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const makeData = (n: number): Item[] =>
|
|
19
|
+
Array.from({length: n}, (_, i) => ({id: `r${i}`, name: `Row ${i}`, value: i}))
|
|
20
|
+
|
|
21
|
+
const meta: Meta<typeof VirtualTable<Item>> = {
|
|
22
|
+
title: 'Components/VirtualTable',
|
|
23
|
+
component: VirtualTable,
|
|
24
|
+
args: {columns, getRowId: (r: Item) => r.id},
|
|
25
|
+
}
|
|
26
|
+
export default meta
|
|
27
|
+
type Story = StoryObj<typeof VirtualTable<Item>>
|
|
28
|
+
|
|
29
|
+
// Below the threshold: every row rendered, one scroll, no windowing/indicator.
|
|
30
|
+
export const SmallList: Story = {
|
|
31
|
+
args: {data: makeData(10)},
|
|
32
|
+
play: async ({canvas}) => {
|
|
33
|
+
// 1 header row + 10 body rows.
|
|
34
|
+
await waitFor(() => expect(canvas.getAllByRole('row')).toHaveLength(11))
|
|
35
|
+
await expect(canvas.getByText('Row 0')).toBeInTheDocument()
|
|
36
|
+
await expect(canvas.getByText('Row 9')).toBeInTheDocument()
|
|
37
|
+
// No floating position indicator at this size.
|
|
38
|
+
await expect(canvas.queryByText(/\/ 10$/)).toBeNull()
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Above the threshold: only a window of rows hits the DOM + the indicator shows.
|
|
43
|
+
export const Virtualized: Story = {
|
|
44
|
+
args: {
|
|
45
|
+
data: makeData(500),
|
|
46
|
+
maxHeight: 300,
|
|
47
|
+
rangeLabel: ({from, to, total}) => `${from}-${to} of ${total}`,
|
|
48
|
+
onVisiblePageChange: fn(),
|
|
49
|
+
},
|
|
50
|
+
play: async ({canvas}) => {
|
|
51
|
+
// Windowed: far fewer than 500 body rows are in the DOM.
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
const rows = canvas.getAllByRole('row')
|
|
54
|
+
expect(rows.length).toBeGreaterThan(1) // header + some
|
|
55
|
+
expect(rows.length).toBeLessThan(80) // NOT 501 — proof of windowing
|
|
56
|
+
})
|
|
57
|
+
// The floating position indicator is present, starting at row 1.
|
|
58
|
+
await expect(canvas.getByText(/^1-\d+ of 500$/)).toBeInTheDocument()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Scrolling reports a new page (for URL sync) once past a page boundary.
|
|
63
|
+
export const ScrollReportsPage: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
data: makeData(500),
|
|
66
|
+
maxHeight: 300,
|
|
67
|
+
pageSize: 50,
|
|
68
|
+
rangeLabel: ({page, pages}) => `page ${page}/${pages}`,
|
|
69
|
+
onVisiblePageChange: fn(),
|
|
70
|
+
},
|
|
71
|
+
play: async ({canvas, args}) => {
|
|
72
|
+
const scroller = canvas.getByRole('table')
|
|
73
|
+
await waitFor(() => expect(canvas.getByText('page 1/10')).toBeInTheDocument())
|
|
74
|
+
// Wait past the 300ms mount suppress-write window (which stops the restore
|
|
75
|
+
// scroll from clobbering the URL) before scrolling for real.
|
|
76
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
77
|
+
// Scroll well past the first 50-row page (row height ~44 → page ≈ 2200px).
|
|
78
|
+
scroller.scrollTo({top: 6000})
|
|
79
|
+
scroller.dispatchEvent(new Event('scroll'))
|
|
80
|
+
// The indicator reflects the new page (proves curPage recomputed on scroll)…
|
|
81
|
+
await waitFor(() => expect(canvas.queryByText('page 1/10')).toBeNull(), {timeout: 3000})
|
|
82
|
+
await expect(canvas.getByText(/^page \d+\/10$/).textContent).not.toBe('page 1/10')
|
|
83
|
+
// …and the visible-page callback fired so a caller could sync the URL.
|
|
84
|
+
await waitFor(() => expect(args.onVisiblePageChange).toHaveBeenCalled(), {timeout: 4000})
|
|
85
|
+
const calls = (args.onVisiblePageChange as ReturnType<typeof fn>).mock.calls
|
|
86
|
+
const maxPage = Math.max(...calls.map((c) => (c[0] as {page: number}).page))
|
|
87
|
+
await expect(maxPage).toBeGreaterThan(1)
|
|
88
|
+
},
|
|
89
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {useEffect, useRef, useState, type ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {
|
|
4
|
+
useReactTable,
|
|
5
|
+
getCoreRowModel,
|
|
6
|
+
getSortedRowModel,
|
|
7
|
+
flexRender,
|
|
8
|
+
type ColumnDef,
|
|
9
|
+
type SortingState,
|
|
10
|
+
type OnChangeFn,
|
|
11
|
+
type Row,
|
|
12
|
+
} from '@tanstack/react-table'
|
|
13
|
+
import {useVirtualizer} from '@tanstack/react-virtual'
|
|
14
|
+
import {styles} from './styles.css'
|
|
15
|
+
|
|
16
|
+
export interface VirtualTableRange {
|
|
17
|
+
/** 1-based index of the first visible row (0 when empty). */
|
|
18
|
+
from: number
|
|
19
|
+
/** 1-based index of the last visible row (0 when empty). */
|
|
20
|
+
to: number
|
|
21
|
+
total: number
|
|
22
|
+
/** 1-based page the first visible row falls in. */
|
|
23
|
+
page: number
|
|
24
|
+
pages: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
interface VirtualTableProps<TData> {
|
|
29
|
+
data: TData[]
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
columns: ColumnDef<TData, any>[]
|
|
32
|
+
/** Controlled sorting (pairs with a SortChip in the caller). */
|
|
33
|
+
sorting?: SortingState
|
|
34
|
+
onSortingChange?: OnChangeFn<SortingState>
|
|
35
|
+
getRowId?: (row: TData, index: number) => string
|
|
36
|
+
onRowClick?: (row: TData) => void
|
|
37
|
+
rowLabel?: (row: TData) => string
|
|
38
|
+
/**
|
|
39
|
+
* 1-based page to restore the scroll position to on mount (read from the URL
|
|
40
|
+
* by the caller). Only meaningful when the list virtualizes.
|
|
41
|
+
*/
|
|
42
|
+
initialPage?: number
|
|
43
|
+
/**
|
|
44
|
+
* Fired (debounced) when the top-visible page changes — wire it to the URL.
|
|
45
|
+
* Only fires in the virtualized branch; small lists render in one scroll and
|
|
46
|
+
* have no page concept.
|
|
47
|
+
*/
|
|
48
|
+
onVisiblePageChange?: (range: VirtualTableRange) => void
|
|
49
|
+
/** Build the floating position-indicator label (i18n stays with the caller). */
|
|
50
|
+
rangeLabel?: (range: VirtualTableRange) => string
|
|
51
|
+
/** Rows per "page" for the URL/indicator math. Default 50. */
|
|
52
|
+
pageSize?: number
|
|
53
|
+
/** Estimated row height for the virtualizer. Default 44. */
|
|
54
|
+
estimateRowHeight?: number
|
|
55
|
+
/** Max height of the scroll viewport once virtualized. Default '70vh'. */
|
|
56
|
+
maxHeight?: number | string
|
|
57
|
+
/** Render every row (no windowing) at or below this count. Default 150. */
|
|
58
|
+
virtualizeThreshold?: number
|
|
59
|
+
emptyLabel?: ReactNode
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A sortable data table that windows its rows above a threshold (default 150)
|
|
64
|
+
* via @tanstack/react-virtual, keeps a floating position indicator, and reports
|
|
65
|
+
* the visible page so the caller can mirror it in the URL. Below the threshold
|
|
66
|
+
* it renders every row in one scroll (find-in-page + a11y intact) — no
|
|
67
|
+
* pagination, no windowing. Web-only (uses DOM scroll measurement).
|
|
68
|
+
*
|
|
69
|
+
* URL-decoupled by design: pass `initialPage` (restore) and `onVisiblePageChange`
|
|
70
|
+
* (persist); the component never touches the router itself.
|
|
71
|
+
*/
|
|
72
|
+
export function VirtualTable<TData>({
|
|
73
|
+
data,
|
|
74
|
+
columns,
|
|
75
|
+
sorting,
|
|
76
|
+
onSortingChange,
|
|
77
|
+
getRowId,
|
|
78
|
+
onRowClick,
|
|
79
|
+
rowLabel,
|
|
80
|
+
initialPage,
|
|
81
|
+
onVisiblePageChange,
|
|
82
|
+
rangeLabel,
|
|
83
|
+
pageSize = 50,
|
|
84
|
+
estimateRowHeight = 44,
|
|
85
|
+
maxHeight = '70vh',
|
|
86
|
+
virtualizeThreshold = 150,
|
|
87
|
+
emptyLabel,
|
|
88
|
+
}: VirtualTableProps<TData>) {
|
|
89
|
+
// Support both controlled sorting (SortChip in the caller) and standalone use.
|
|
90
|
+
const [internalSorting, setInternalSorting] = useState<SortingState>([])
|
|
91
|
+
const sortingState = sorting ?? internalSorting
|
|
92
|
+
const handleSortingChange = onSortingChange ?? setInternalSorting
|
|
93
|
+
|
|
94
|
+
const table = useReactTable({
|
|
95
|
+
data,
|
|
96
|
+
columns,
|
|
97
|
+
state: {sorting: sortingState},
|
|
98
|
+
onSortingChange: handleSortingChange,
|
|
99
|
+
getRowId,
|
|
100
|
+
getCoreRowModel: getCoreRowModel(),
|
|
101
|
+
getSortedRowModel: getSortedRowModel(),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const rows = table.getRowModel().rows
|
|
105
|
+
const virtualize = rows.length > virtualizeThreshold
|
|
106
|
+
|
|
107
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
108
|
+
const rv = useVirtualizer({
|
|
109
|
+
count: rows.length,
|
|
110
|
+
getScrollElement: () => scrollRef.current,
|
|
111
|
+
estimateSize: () => estimateRowHeight,
|
|
112
|
+
overscan: 12,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Restore the scroll position from `initialPage` on mount, and suppress the
|
|
116
|
+
// page-change callback briefly so that restore scroll doesn't immediately
|
|
117
|
+
// report (and overwrite) the URL.
|
|
118
|
+
const suppressWrite = useRef(true)
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!virtualize) {
|
|
121
|
+
suppressWrite.current = false
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (initialPage && initialPage > 1) {
|
|
125
|
+
rv.scrollToIndex((initialPage - 1) * pageSize, {align: 'start'})
|
|
126
|
+
}
|
|
127
|
+
const id = setTimeout(() => {
|
|
128
|
+
suppressWrite.current = false
|
|
129
|
+
}, 300)
|
|
130
|
+
return () => clearTimeout(id)
|
|
131
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
132
|
+
}, [virtualize])
|
|
133
|
+
|
|
134
|
+
const vItems = rv.getVirtualItems()
|
|
135
|
+
const firstIndex = vItems[0]?.index ?? 0
|
|
136
|
+
const lastIndex = vItems.length ? vItems[vItems.length - 1].index : 0
|
|
137
|
+
const pages = Math.max(1, Math.ceil(rows.length / pageSize))
|
|
138
|
+
// page = which pageSize-sized block the top visible row is in, so it only
|
|
139
|
+
// changes when you cross a boundary (no per-scroll-tick churn).
|
|
140
|
+
const curPage = Math.floor(Math.max(0, firstIndex) / pageSize) + 1
|
|
141
|
+
|
|
142
|
+
const range: VirtualTableRange = {
|
|
143
|
+
from: rows.length ? firstIndex + 1 : 0,
|
|
144
|
+
to: rows.length ? lastIndex + 1 : 0,
|
|
145
|
+
total: rows.length,
|
|
146
|
+
page: curPage,
|
|
147
|
+
pages,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Report the visible page so the caller can sync the URL. Fire once per
|
|
151
|
+
// distinct page rather than debouncing a timer — `curPage` only changes at a
|
|
152
|
+
// pageSize boundary (coarse), and react-virtual's frequent re-renders during
|
|
153
|
+
// scroll/measurement would keep resetting a debounce timer so it never fires.
|
|
154
|
+
// The `lastReported` guard prevents redundant calls; callers use replace, so
|
|
155
|
+
// there's no history churn.
|
|
156
|
+
const lastReported = useRef<number | null>(null)
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!virtualize || !onVisiblePageChange) return
|
|
159
|
+
if (suppressWrite.current) {
|
|
160
|
+
// Still in the mount-restore window — record the page so we don't emit a
|
|
161
|
+
// spurious change once it clears, but don't call out.
|
|
162
|
+
lastReported.current = curPage
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (lastReported.current === curPage) return
|
|
166
|
+
lastReported.current = curPage
|
|
167
|
+
onVisiblePageChange({
|
|
168
|
+
from: rows.length ? firstIndex + 1 : 0,
|
|
169
|
+
to: rows.length ? lastIndex + 1 : 0,
|
|
170
|
+
total: rows.length,
|
|
171
|
+
page: curPage,
|
|
172
|
+
pages,
|
|
173
|
+
})
|
|
174
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
175
|
+
}, [curPage, virtualize])
|
|
176
|
+
|
|
177
|
+
const renderRow = (
|
|
178
|
+
row: Row<TData>,
|
|
179
|
+
index: number,
|
|
180
|
+
positioned?: ReturnType<typeof styles.rowAbsolute>,
|
|
181
|
+
measureRef?: (node: Element | null) => void,
|
|
182
|
+
) => (
|
|
183
|
+
<html.div
|
|
184
|
+
key={row.id}
|
|
185
|
+
ref={measureRef}
|
|
186
|
+
data-index={index}
|
|
187
|
+
role="row"
|
|
188
|
+
aria-label={rowLabel?.(row.original)}
|
|
189
|
+
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
|
|
190
|
+
style={[styles.row, Boolean(onRowClick) && styles.rowClickable, positioned]}
|
|
191
|
+
>
|
|
192
|
+
{row.getVisibleCells().map((cell) => (
|
|
193
|
+
<html.div
|
|
194
|
+
key={cell.id}
|
|
195
|
+
role="cell"
|
|
196
|
+
style={[styles.cell, styles.cellFlex(cell.column.getSize())]}
|
|
197
|
+
>
|
|
198
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
199
|
+
</html.div>
|
|
200
|
+
))}
|
|
201
|
+
</html.div>
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const label =
|
|
205
|
+
rangeLabel?.(range) ?? (rows.length ? `${range.from}–${range.to} / ${range.total}` : '')
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<html.div style={styles.wrap}>
|
|
209
|
+
<html.div
|
|
210
|
+
ref={scrollRef}
|
|
211
|
+
role="table"
|
|
212
|
+
style={[styles.scroll, virtualize && styles.scrollMax(maxHeight)]}
|
|
213
|
+
>
|
|
214
|
+
<html.div role="rowgroup" style={styles.head}>
|
|
215
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
216
|
+
<html.div key={headerGroup.id} role="row" style={styles.headRow}>
|
|
217
|
+
{headerGroup.headers.map((header) => {
|
|
218
|
+
const col = header.column
|
|
219
|
+
const sorted = col.getIsSorted()
|
|
220
|
+
return (
|
|
221
|
+
<html.div
|
|
222
|
+
key={header.id}
|
|
223
|
+
role="columnheader"
|
|
224
|
+
aria-sort={
|
|
225
|
+
sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined
|
|
226
|
+
}
|
|
227
|
+
onClick={col.getCanSort() ? col.getToggleSortingHandler() : undefined}
|
|
228
|
+
style={[
|
|
229
|
+
styles.headCell,
|
|
230
|
+
col.getCanSort() && styles.headCellSortable,
|
|
231
|
+
styles.cellFlex(header.getSize()),
|
|
232
|
+
]}
|
|
233
|
+
>
|
|
234
|
+
{header.isPlaceholder
|
|
235
|
+
? null
|
|
236
|
+
: flexRender(col.columnDef.header, header.getContext())}
|
|
237
|
+
{sorted ? (
|
|
238
|
+
<html.span style={styles.sortMark}>{sorted === 'asc' ? '↑' : '↓'}</html.span>
|
|
239
|
+
) : null}
|
|
240
|
+
</html.div>
|
|
241
|
+
)
|
|
242
|
+
})}
|
|
243
|
+
</html.div>
|
|
244
|
+
))}
|
|
245
|
+
</html.div>
|
|
246
|
+
|
|
247
|
+
{rows.length === 0 ? (
|
|
248
|
+
<html.div style={styles.empty}>{emptyLabel}</html.div>
|
|
249
|
+
) : (
|
|
250
|
+
<html.div
|
|
251
|
+
role="rowgroup"
|
|
252
|
+
style={virtualize ? styles.bodyTotal(rv.getTotalSize()) : undefined}
|
|
253
|
+
>
|
|
254
|
+
{virtualize
|
|
255
|
+
? vItems.map((vi) =>
|
|
256
|
+
renderRow(
|
|
257
|
+
rows[vi.index],
|
|
258
|
+
vi.index,
|
|
259
|
+
styles.rowAbsolute(vi.start),
|
|
260
|
+
rv.measureElement,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
: rows.map((row, i) => renderRow(row, i))}
|
|
264
|
+
</html.div>
|
|
265
|
+
)}
|
|
266
|
+
</html.div>
|
|
267
|
+
|
|
268
|
+
{virtualize && rows.length > 0 ? (
|
|
269
|
+
<html.div aria-live="polite" style={styles.pos}>
|
|
270
|
+
{label}
|
|
271
|
+
</html.div>
|
|
272
|
+
) : null}
|
|
273
|
+
</html.div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
5
|
+
|
|
6
|
+
export const styles = css.create({
|
|
7
|
+
wrap: {
|
|
8
|
+
position: 'relative',
|
|
9
|
+
width: '100%',
|
|
10
|
+
},
|
|
11
|
+
scroll: {
|
|
12
|
+
position: 'relative',
|
|
13
|
+
width: '100%',
|
|
14
|
+
borderWidth: 1,
|
|
15
|
+
borderStyle: 'solid',
|
|
16
|
+
borderColor: colors.border,
|
|
17
|
+
borderRadius: radii.md,
|
|
18
|
+
backgroundColor: colors.bgCard,
|
|
19
|
+
},
|
|
20
|
+
// Dynamic: only virtualized lists get a capped, scrollable viewport.
|
|
21
|
+
scrollMax: (maxHeight: number | string) => ({
|
|
22
|
+
maxHeight,
|
|
23
|
+
overflowY: 'auto',
|
|
24
|
+
overscrollBehavior: 'contain',
|
|
25
|
+
}),
|
|
26
|
+
head: {
|
|
27
|
+
position: 'sticky',
|
|
28
|
+
top: 0,
|
|
29
|
+
zIndex: 1,
|
|
30
|
+
backgroundColor: colors.bgCard,
|
|
31
|
+
},
|
|
32
|
+
headRow: {
|
|
33
|
+
display: 'flex',
|
|
34
|
+
flexDirection: 'row',
|
|
35
|
+
borderBottomWidth: 1,
|
|
36
|
+
borderBottomStyle: 'solid',
|
|
37
|
+
borderBottomColor: colors.border,
|
|
38
|
+
},
|
|
39
|
+
headCell: {
|
|
40
|
+
display: 'flex',
|
|
41
|
+
flexDirection: 'row',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
gap: spacing.xs,
|
|
44
|
+
paddingTop: spacing.sm,
|
|
45
|
+
paddingBottom: spacing.sm,
|
|
46
|
+
paddingLeft: spacing.md,
|
|
47
|
+
paddingRight: spacing.md,
|
|
48
|
+
fontFamily: typography.fontFamily,
|
|
49
|
+
fontSize: typography.fontSizeXs,
|
|
50
|
+
fontWeight: typography.fontWeightSemibold,
|
|
51
|
+
textTransform: 'uppercase' as const,
|
|
52
|
+
letterSpacing: '0.03em',
|
|
53
|
+
color: colors.textMuted,
|
|
54
|
+
},
|
|
55
|
+
headCellSortable: {
|
|
56
|
+
cursor: 'pointer',
|
|
57
|
+
userSelect: 'none',
|
|
58
|
+
color: {default: colors.textMuted, ':hover': colors.text},
|
|
59
|
+
},
|
|
60
|
+
sortMark: {
|
|
61
|
+
fontSize: typography.fontSizeXs,
|
|
62
|
+
color: colors.accent,
|
|
63
|
+
},
|
|
64
|
+
bodyTotal: (height: number) => ({
|
|
65
|
+
position: 'relative',
|
|
66
|
+
width: '100%',
|
|
67
|
+
height,
|
|
68
|
+
}),
|
|
69
|
+
row: {
|
|
70
|
+
display: 'flex',
|
|
71
|
+
flexDirection: 'row',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
width: '100%',
|
|
74
|
+
borderBottomWidth: 1,
|
|
75
|
+
borderBottomStyle: 'solid',
|
|
76
|
+
borderBottomColor: colors.border,
|
|
77
|
+
},
|
|
78
|
+
rowClickable: {
|
|
79
|
+
cursor: 'pointer',
|
|
80
|
+
backgroundColor: {default: 'transparent', ':hover': colors.bgCardHover},
|
|
81
|
+
},
|
|
82
|
+
// Dynamic: absolute placement of a windowed row.
|
|
83
|
+
rowAbsolute: (y: number) => ({
|
|
84
|
+
position: 'absolute',
|
|
85
|
+
top: 0,
|
|
86
|
+
left: 0,
|
|
87
|
+
transform: `translateY(${y}px)`,
|
|
88
|
+
}),
|
|
89
|
+
cell: {
|
|
90
|
+
paddingTop: spacing.sm,
|
|
91
|
+
paddingBottom: spacing.sm,
|
|
92
|
+
paddingLeft: spacing.md,
|
|
93
|
+
paddingRight: spacing.md,
|
|
94
|
+
fontFamily: typography.fontFamily,
|
|
95
|
+
fontSize: typography.fontSizeSm,
|
|
96
|
+
color: colors.text,
|
|
97
|
+
overflow: 'hidden',
|
|
98
|
+
textOverflow: 'ellipsis',
|
|
99
|
+
whiteSpace: 'nowrap',
|
|
100
|
+
},
|
|
101
|
+
// Dynamic: proportional column width from the TanStack column size (fr-like).
|
|
102
|
+
cellFlex: (size: number) => ({
|
|
103
|
+
flexGrow: size,
|
|
104
|
+
flexShrink: 1,
|
|
105
|
+
flexBasis: 0,
|
|
106
|
+
minWidth: 0,
|
|
107
|
+
}),
|
|
108
|
+
empty: {
|
|
109
|
+
paddingTop: spacing.xl,
|
|
110
|
+
paddingBottom: spacing.xl,
|
|
111
|
+
textAlign: 'center' as const,
|
|
112
|
+
color: colors.textMuted,
|
|
113
|
+
fontFamily: typography.fontFamily,
|
|
114
|
+
fontSize: typography.fontSizeSm,
|
|
115
|
+
},
|
|
116
|
+
// Floating position indicator, pinned bottom-right of the viewport.
|
|
117
|
+
pos: {
|
|
118
|
+
position: 'absolute',
|
|
119
|
+
right: spacing.sm,
|
|
120
|
+
bottom: spacing.sm,
|
|
121
|
+
paddingTop: 3,
|
|
122
|
+
paddingBottom: 3,
|
|
123
|
+
paddingLeft: spacing.sm,
|
|
124
|
+
paddingRight: spacing.sm,
|
|
125
|
+
borderRadius: radii.full,
|
|
126
|
+
backgroundColor: colors.bgCardHover,
|
|
127
|
+
borderWidth: 1,
|
|
128
|
+
borderStyle: 'solid',
|
|
129
|
+
borderColor: colors.border,
|
|
130
|
+
color: colors.textMuted,
|
|
131
|
+
fontFamily: typography.fontFamily,
|
|
132
|
+
fontSize: typography.fontSizeXs,
|
|
133
|
+
pointerEvents: 'none',
|
|
134
|
+
},
|
|
135
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -56,6 +56,9 @@ export {
|
|
|
56
56
|
export {Tag, type TagVariant, type TagSize} from './components/Tag/Tag'
|
|
57
57
|
export {TagGroup} from './components/TagGroup/TagGroup'
|
|
58
58
|
export {Table, type TableVariant, type TableSize} from './components/Table/Table'
|
|
59
|
+
// Web-only (DOM scroll measurement + @tanstack/react-virtual); intentionally
|
|
60
|
+
// absent from index.native.ts.
|
|
61
|
+
export {VirtualTable, type VirtualTableRange} from './components/VirtualTable/VirtualTable'
|
|
59
62
|
// Side-effect import registers our augmentation of TanStack's ColumnMeta
|
|
60
63
|
// (stackLabel / isActions). Standalone module so the augmentation lands
|
|
61
64
|
// whenever this package is in a consumer's TS program, even if FromTanstack
|