@duro-app/ui 0.41.0 → 0.43.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/Callout/Callout.d.ts +7 -1
- package/dist/components/Callout/Callout.d.ts.map +1 -1
- package/dist/components/Callout/Callout.stories.d.ts +1 -1
- package/dist/components/Callout/Callout.stories.d.ts.map +1 -1
- package/dist/components/Callout/styles.css.d.ts +14 -5
- package/dist/components/Callout/styles.css.d.ts.map +1 -1
- 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 +1914 -1658
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/Callout/Callout.meta.ts +1 -1
- package/src/components/Callout/Callout.stories.tsx +24 -11
- package/src/components/Callout/Callout.tsx +16 -3
- package/src/components/Callout/styles.css.ts +17 -5
- 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.43.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.43.0"
|
|
58
59
|
},
|
|
59
60
|
"devDependencies": {
|
|
60
61
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Block-level informational message with icon and colored background. More prominent than Alert. Use for important notices, onboarding tips, or contextual guidance that should stand out.',
|
|
5
|
+
'Block-level informational message with icon and colored background. More prominent than Alert. Use for important notices, onboarding tips, or contextual guidance that should stand out. `align` controls icon↔message vertical alignment: `center` (default) for short messages, `start` for long multi-paragraph content.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Important notices that need visual prominence',
|
|
8
8
|
'Onboarding tips or contextual guidance',
|
|
@@ -11,15 +11,21 @@ const meta: Meta<typeof Callout> = {
|
|
|
11
11
|
control: 'select',
|
|
12
12
|
options: ['error', 'success', 'warning', 'info'],
|
|
13
13
|
},
|
|
14
|
+
align: {
|
|
15
|
+
control: 'inline-radio',
|
|
16
|
+
options: ['center', 'start'],
|
|
17
|
+
},
|
|
14
18
|
},
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
export default meta
|
|
18
22
|
type Story = StoryObj<typeof Callout>
|
|
19
23
|
|
|
24
|
+
// Long, multi-paragraph messages align the icon to the first line.
|
|
20
25
|
export const Info: Story = {
|
|
21
26
|
args: {
|
|
22
27
|
variant: 'info',
|
|
28
|
+
align: 'start',
|
|
23
29
|
children:
|
|
24
30
|
'A new version of the application is available. Please save your work and refresh the page to get the latest features and security updates.',
|
|
25
31
|
},
|
|
@@ -28,6 +34,7 @@ export const Info: Story = {
|
|
|
28
34
|
export const Warning: Story = {
|
|
29
35
|
args: {
|
|
30
36
|
variant: 'warning',
|
|
37
|
+
align: 'start',
|
|
31
38
|
children:
|
|
32
39
|
"It looks like your certificate isn't installed yet. Install the .p12 file from your email, then click the button below. After installing the certificate, you may need to close and reopen your browser for it to be recognized.",
|
|
33
40
|
},
|
|
@@ -36,6 +43,7 @@ export const Warning: Story = {
|
|
|
36
43
|
export const Success: Story = {
|
|
37
44
|
args: {
|
|
38
45
|
variant: 'success',
|
|
46
|
+
align: 'start',
|
|
39
47
|
children:
|
|
40
48
|
'Your account has been created and your certificate is installed. You can now access all resources assigned to your groups. Check your email for a welcome guide with next steps.',
|
|
41
49
|
},
|
|
@@ -44,16 +52,24 @@ export const Success: Story = {
|
|
|
44
52
|
export const Error: Story = {
|
|
45
53
|
args: {
|
|
46
54
|
variant: 'error',
|
|
55
|
+
align: 'start',
|
|
47
56
|
children:
|
|
48
57
|
'We could not verify your identity. This may happen if your invite link has expired or was already used. Please contact your administrator to request a new invitation.',
|
|
49
58
|
},
|
|
50
59
|
}
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
// Short single-line message — the default `align="center"` vertically centres
|
|
62
|
+
// the icon against the text.
|
|
63
|
+
export const ShortCentered: Story = {
|
|
54
64
|
args: {
|
|
55
|
-
variant: '
|
|
56
|
-
children: '
|
|
65
|
+
variant: 'success',
|
|
66
|
+
children: "You're all caught up — no requests awaiting review.",
|
|
67
|
+
},
|
|
68
|
+
play: async ({canvas}) => {
|
|
69
|
+
await expect(canvas.getByRole('note')).toBeInTheDocument()
|
|
70
|
+
await expect(canvas.getByText(/all caught up/)).toBeInTheDocument()
|
|
71
|
+
// Exactly one icon (svg) — the component's own, no duplicate.
|
|
72
|
+
await expect(canvas.getByRole('note').querySelectorAll('svg')).toHaveLength(1)
|
|
57
73
|
},
|
|
58
74
|
}
|
|
59
75
|
|
|
@@ -72,22 +88,19 @@ const stackStyles = css.create({
|
|
|
72
88
|
export const AllVariants: Story = {
|
|
73
89
|
render: () => (
|
|
74
90
|
<html.div style={stackStyles.stack}>
|
|
75
|
-
<Callout variant="error">
|
|
91
|
+
<Callout variant="error" align="start">
|
|
76
92
|
We could not verify your identity. This may happen if your invite link has expired or was
|
|
77
93
|
already used. Please contact your administrator.
|
|
78
94
|
</Callout>
|
|
79
|
-
<Callout variant="success">
|
|
95
|
+
<Callout variant="success" align="start">
|
|
80
96
|
Your account has been created and your certificate is installed. You can now access all
|
|
81
97
|
resources assigned to your groups.
|
|
82
98
|
</Callout>
|
|
83
|
-
<Callout variant="warning">
|
|
99
|
+
<Callout variant="warning" align="start">
|
|
84
100
|
It looks like your certificate is not installed yet. Install the .p12 file from your email,
|
|
85
101
|
then click the button below.
|
|
86
102
|
</Callout>
|
|
87
|
-
<Callout variant="info">
|
|
88
|
-
A new version of the application is available. Please save your work and refresh the page to
|
|
89
|
-
get the latest features.
|
|
90
|
-
</Callout>
|
|
103
|
+
<Callout variant="info">A new version is available.</Callout>
|
|
91
104
|
</html.div>
|
|
92
105
|
),
|
|
93
106
|
play: async ({canvas}) => {
|
|
@@ -17,6 +17,12 @@ interface CalloutProps {
|
|
|
17
17
|
variant?: CalloutVariant
|
|
18
18
|
/** Built-in icon name, custom ReactNode, or false to hide. Defaults to variant icon. */
|
|
19
19
|
icon?: IconName | ReactNode | false
|
|
20
|
+
/**
|
|
21
|
+
* Vertical alignment of the icon against the message. `center` (default)
|
|
22
|
+
* suits short single/two-line messages; `start` aligns the icon to the first
|
|
23
|
+
* line for long multi-paragraph content.
|
|
24
|
+
*/
|
|
25
|
+
align?: 'center' | 'start'
|
|
20
26
|
children: ReactNode
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -27,13 +33,20 @@ function resolveIcon(icon: CalloutProps['icon'], variant: CalloutVariant): React
|
|
|
27
33
|
return icon
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
export function Callout({variant = 'info', icon, children}: CalloutProps) {
|
|
36
|
+
export function Callout({variant = 'info', icon, align = 'center', children}: CalloutProps) {
|
|
31
37
|
const resolvedIcon = resolveIcon(icon, variant)
|
|
32
38
|
|
|
33
39
|
return (
|
|
34
|
-
<html.div
|
|
40
|
+
<html.div
|
|
41
|
+
role="note"
|
|
42
|
+
style={[
|
|
43
|
+
styles.base,
|
|
44
|
+
styles[variant],
|
|
45
|
+
align === 'center' ? styles.alignCenter : styles.alignStart,
|
|
46
|
+
]}
|
|
47
|
+
>
|
|
35
48
|
{resolvedIcon && <html.span style={styles.icon}>{resolvedIcon}</html.span>}
|
|
36
|
-
{children}
|
|
49
|
+
<html.div style={styles.content}>{children}</html.div>
|
|
37
50
|
</html.div>
|
|
38
51
|
)
|
|
39
52
|
}
|
|
@@ -5,23 +5,35 @@ import {typography} from '@duro-app/tokens/tokens/typography.css'
|
|
|
5
5
|
|
|
6
6
|
export const styles = css.create({
|
|
7
7
|
base: {
|
|
8
|
+
display: 'flex',
|
|
9
|
+
flexDirection: 'row',
|
|
10
|
+
gap: spacing.sm,
|
|
8
11
|
padding: spacing.md,
|
|
9
12
|
borderRadius: radii.sm,
|
|
10
13
|
borderWidth: 1,
|
|
11
14
|
borderStyle: 'solid',
|
|
12
15
|
fontSize: typography.fontSizeSm,
|
|
13
16
|
lineHeight: typography.lineHeight,
|
|
14
|
-
|
|
17
|
+
},
|
|
18
|
+
// Vertically centres the icon with the message — the right default for the
|
|
19
|
+
// short, single/two-line messages callouts usually carry.
|
|
20
|
+
alignCenter: {
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
},
|
|
23
|
+
// Aligns the icon to the first line — for long, multi-paragraph messages.
|
|
24
|
+
alignStart: {
|
|
25
|
+
alignItems: 'flex-start',
|
|
15
26
|
},
|
|
16
27
|
icon: {
|
|
17
|
-
|
|
18
|
-
marginRight: spacing.sm,
|
|
19
|
-
marginBottom: spacing.xs,
|
|
20
|
-
marginTop: 2,
|
|
28
|
+
flexShrink: 0,
|
|
21
29
|
display: 'inline-flex',
|
|
22
30
|
alignItems: 'center',
|
|
23
31
|
justifyContent: 'center',
|
|
24
32
|
},
|
|
33
|
+
content: {
|
|
34
|
+
flexGrow: 1,
|
|
35
|
+
minWidth: 0,
|
|
36
|
+
},
|
|
25
37
|
error: {
|
|
26
38
|
backgroundColor: colors.errorBg,
|
|
27
39
|
borderColor: colors.errorBorder,
|
|
@@ -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
|
+
}
|