@duro-app/ui 0.40.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.
Files changed (33) hide show
  1. package/dist/components/Icon/Icon.d.ts +1 -1
  2. package/dist/components/Icon/Icon.d.ts.map +1 -1
  3. package/dist/components/SideNav/SideNav.d.ts +16 -1
  4. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  5. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  6. package/dist/components/SideNav/SideNav.stories.d.ts +1 -0
  7. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  8. package/dist/components/SideNav/styles.css.d.ts +48 -0
  9. package/dist/components/SideNav/styles.css.d.ts.map +1 -1
  10. package/dist/components/VirtualTable/VirtualTable.d.ts +57 -0
  11. package/dist/components/VirtualTable/VirtualTable.d.ts.map +1 -0
  12. package/dist/components/VirtualTable/VirtualTable.meta.d.ts +3 -0
  13. package/dist/components/VirtualTable/VirtualTable.meta.d.ts.map +1 -0
  14. package/dist/components/VirtualTable/VirtualTable.stories.d.ts +14 -0
  15. package/dist/components/VirtualTable/VirtualTable.stories.d.ts.map +1 -0
  16. package/dist/components/VirtualTable/styles.css.d.ts +128 -0
  17. package/dist/components/VirtualTable/styles.css.d.ts.map +1 -0
  18. package/dist/index.css +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2286 -1936
  22. package/dist/index.js.map +1 -1
  23. package/package.json +3 -2
  24. package/src/components/Icon/Icon.tsx +46 -0
  25. package/src/components/SideNav/SideNav.meta.ts +17 -11
  26. package/src/components/SideNav/SideNav.stories.tsx +47 -3
  27. package/src/components/SideNav/SideNav.tsx +34 -2
  28. package/src/components/SideNav/styles.css.ts +51 -1
  29. package/src/components/VirtualTable/VirtualTable.meta.ts +43 -0
  30. package/src/components/VirtualTable/VirtualTable.stories.tsx +89 -0
  31. package/src/components/VirtualTable/VirtualTable.tsx +275 -0
  32. package/src/components/VirtualTable/styles.css.ts +135 -0
  33. package/src/index.ts +3 -0
@@ -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