@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.
Files changed (29) hide show
  1. package/dist/components/Callout/Callout.d.ts +7 -1
  2. package/dist/components/Callout/Callout.d.ts.map +1 -1
  3. package/dist/components/Callout/Callout.stories.d.ts +1 -1
  4. package/dist/components/Callout/Callout.stories.d.ts.map +1 -1
  5. package/dist/components/Callout/styles.css.d.ts +14 -5
  6. package/dist/components/Callout/styles.css.d.ts.map +1 -1
  7. package/dist/components/VirtualTable/VirtualTable.d.ts +57 -0
  8. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -0
  9. package/dist/components/VirtualTable/VirtualTable.meta.d.ts +3 -0
  10. package/dist/components/VirtualTable/VirtualTable.meta.d.ts.map +1 -0
  11. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +14 -0
  12. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -0
  13. package/dist/components/VirtualTable/styles.css.d.ts +128 -0
  14. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -0
  15. package/dist/index.css +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1914 -1658
  19. package/dist/index.js.map +1 -1
  20. package/package.json +3 -2
  21. package/src/components/Callout/Callout.meta.ts +1 -1
  22. package/src/components/Callout/Callout.stories.tsx +24 -11
  23. package/src/components/Callout/Callout.tsx +16 -3
  24. package/src/components/Callout/styles.css.ts +17 -5
  25. package/src/components/VirtualTable/VirtualTable.meta.ts +43 -0
  26. package/src/components/VirtualTable/VirtualTable.stories.tsx +89 -0
  27. package/src/components/VirtualTable/VirtualTable.tsx +275 -0
  28. package/src/components/VirtualTable/styles.css.ts +135 -0
  29. 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.41.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
- "@duro-app/tokens": "^0.41.0"
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
- export const ShortText: Story = {
53
- name: 'Short text (still wraps)',
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: 'info',
56
- children: 'System update available.',
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 role="note" style={[styles.base, styles[variant]]}>
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
- overflow: 'hidden', // contain the float
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
- float: 'left',
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
+ }