@proteos/ui 0.31.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 (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,556 @@
1
+ import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
2
+ import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'
3
+ import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'
4
+ import {
5
+ type ColumnDef,
6
+ flexRender,
7
+ getCoreRowModel,
8
+ type OnChangeFn,
9
+ type Row,
10
+ type SortingState,
11
+ useReactTable,
12
+ } from '@tanstack/react-table'
13
+ import { useVirtualizer } from '@tanstack/react-virtual'
14
+ import {
15
+ type CSSProperties,
16
+ type KeyboardEvent,
17
+ type MouseEvent,
18
+ type ReactNode,
19
+ useEffect,
20
+ useLayoutEffect,
21
+ useMemo,
22
+ useRef,
23
+ useState,
24
+ } from 'react'
25
+
26
+ import { cn } from '../../lib/cn'
27
+ import {
28
+ isColumnDragData,
29
+ type SortDirection,
30
+ Table,
31
+ TableCell,
32
+ TableHeader,
33
+ TableHeaderCell,
34
+ TableHeaderSpacer,
35
+ TableRow,
36
+ TableScroller,
37
+ type TableVariants,
38
+ } from '../table'
39
+ import { DataTableEmpty } from './states/data-table-empty'
40
+ import { DataTableError } from './states/data-table-error'
41
+ import { DataTableSkeleton } from './states/data-table-skeleton'
42
+ import { useInfiniteScrollSentinel } from './use-infinite-scroll-sentinel'
43
+
44
+ /* =========================================================================
45
+ DataTable — generic virtualized infinite-scroll table.
46
+ - TanStack Table v8 owns the column model and sorting state.
47
+ - TanStack Virtual virtualizes the body against an internal scroller ref.
48
+ - An IntersectionObserver sentinel triggers `query.fetchNextPage()` ~200 px
49
+ before the user reaches the bottom. Visuals (loader, end marker, empty,
50
+ error) match the Anthropic design's `pt-loader`/`pt-end`/`pt-empty`.
51
+ - The component is data-fetching-shape-agnostic: it accepts anything
52
+ conforming to InfiniteQueryLike (matches @tanstack/react-query's
53
+ useInfiniteQuery return shape).
54
+ ========================================================================= */
55
+
56
+ type InfinitePage<TRow> = { data: TRow[]; meta?: { items_total?: number } }
57
+
58
+ export type InfiniteQueryLike<TRow> = {
59
+ data?: { pages: InfinitePage<TRow>[] } | undefined
60
+ isLoading: boolean
61
+ isFetchingNextPage: boolean
62
+ hasNextPage: boolean | undefined
63
+ error: Error | null
64
+ fetchNextPage: () => void
65
+ refetch?: () => void
66
+ }
67
+
68
+ export type DataTableProps<TRow> = {
69
+ query: InfiniteQueryLike<TRow>
70
+ columns: ColumnDef<TRow>[]
71
+ sorting?: SortingState
72
+ onSortingChange?: OnChangeFn<SortingState>
73
+ rowHeight?: number
74
+ overscan?: number
75
+ /**
76
+ * Called when a row is clicked. Provides both the original data row and
77
+ * the underlying event so consumers can inspect modifier keys.
78
+ */
79
+ onRowClick?: (
80
+ row: TRow,
81
+ event: MouseEvent<HTMLDivElement> | KeyboardEvent<HTMLDivElement>,
82
+ ) => void
83
+ zebra?: boolean
84
+ emptyState?: ReactNode
85
+ errorState?: ReactNode
86
+ loadingState?: ReactNode
87
+ /**
88
+ * Optional accessor used as the React key for each row. Defaults to a
89
+ * row's `id` field (if present), then `slug`, then index.
90
+ */
91
+ getRowId?: (row: TRow, index: number) => string
92
+ className?: string
93
+ ariaLabel?: string
94
+ /**
95
+ * Enable horizontal drag-and-drop reordering of columns. When on, a grip
96
+ * handle and `monitorForElements` are wired up; on a successful reorder
97
+ * `onReorderColumn` fires with the moved column's id and its new index.
98
+ * Each `ColumnDef` must carry a stable `id` for events to be routable.
99
+ */
100
+ enableColumnReordering?: boolean
101
+ /**
102
+ * Enable an `×` remove affordance on each column header. The handler is
103
+ * called with the column's id; the parent decides what removal means
104
+ * (e.g. filter the displayed `columns` array).
105
+ */
106
+ enableColumnRemoval?: boolean
107
+ onReorderColumn?: (params: { id: string; toIndex: number }) => void
108
+ onRemoveColumn?: (id: string) => void
109
+ /**
110
+ * Visual chrome around the table. `panel` (default) wraps the table in a
111
+ * bordered, rounded, soft-shadowed card. `bare` strips that chrome so the
112
+ * table can sit flush with its container — used when the surrounding
113
+ * surface already supplies borders (e.g. the Query Builder's results pane).
114
+ */
115
+ variant?: TableVariants['variant']
116
+ /**
117
+ * Optional per-row wrapper. When provided, the row's cell content is
118
+ * passed through this function, which may render context providers (e.g.
119
+ * a row-scoped TanStack Form for inline editing) around the cells. The
120
+ * default is identity — cells render directly inside the `<TableRow>`.
121
+ */
122
+ rowWrapper?: (row: Row<TRow>, cells: ReactNode) => ReactNode
123
+ /**
124
+ * Extra rows rendered alongside the server data. Used for inline-editable
125
+ * draft rows that are not yet persisted. `start` rows render above the
126
+ * server data, `end` rows below. They participate in the same column
127
+ * model, virtualization, and `rowWrapper` as server rows.
128
+ */
129
+ extraRows?: { start?: TRow[]; end?: TRow[] }
130
+ /**
131
+ * Slot rendered ABOVE the body (above the virtualized region). Used for
132
+ * controls like an Add row button at slot='start'. Stays put when the
133
+ * body scrolls (sits inside the header sticky region or just below it).
134
+ */
135
+ bodyBefore?: ReactNode
136
+ /**
137
+ * Slot rendered BELOW the body. Used for controls like an Add row button
138
+ * at slot='end'. Sits inside the scrollable area below the rows.
139
+ */
140
+ bodyAfter?: ReactNode
141
+ /**
142
+ * Draw vertical gridlines between body cells. The header always has them;
143
+ * the body opts in (e.g. inline-edit mode wants the full Excel-style grid).
144
+ * Default false — read-only tables stay borderless in the body.
145
+ */
146
+ columnBorders?: boolean
147
+ }
148
+
149
+ const DEFAULT_ROW_HEIGHT = 32
150
+
151
+ export function DataTable<TRow>({
152
+ query,
153
+ columns,
154
+ sorting,
155
+ onSortingChange,
156
+ rowHeight = DEFAULT_ROW_HEIGHT,
157
+ overscan = 8,
158
+ onRowClick,
159
+ zebra = true,
160
+ emptyState,
161
+ errorState,
162
+ loadingState,
163
+ getRowId,
164
+ className,
165
+ ariaLabel,
166
+ enableColumnReordering = false,
167
+ enableColumnRemoval = false,
168
+ onReorderColumn,
169
+ onRemoveColumn,
170
+ variant = 'panel',
171
+ rowWrapper,
172
+ extraRows,
173
+ bodyBefore,
174
+ bodyAfter,
175
+ columnBorders = false,
176
+ }: DataTableProps<TRow>) {
177
+ const rows = useMemo<TRow[]>(() => {
178
+ const pages = query.data?.pages
179
+ const dataRows = pages && pages.length > 0 ? pages.flatMap((p) => p.data) : []
180
+ const startExtras = extraRows?.start ?? []
181
+ const endExtras = extraRows?.end ?? []
182
+ if (startExtras.length === 0 && endExtras.length === 0) return dataRows
183
+ return [...startExtras, ...dataRows, ...endExtras]
184
+ }, [query.data, extraRows])
185
+
186
+ const table = useReactTable({
187
+ data: rows,
188
+ columns,
189
+ state: { sorting: sorting ?? [] },
190
+ ...(onSortingChange ? { onSortingChange } : {}),
191
+ manualSorting: true,
192
+ getCoreRowModel: getCoreRowModel(),
193
+ getRowId: getRowId
194
+ ? (row, index) => getRowId(row, index)
195
+ : (row, index) => {
196
+ const r = row as Record<string, unknown>
197
+ if (typeof r.id === 'string' || typeof r.id === 'number') return String(r.id)
198
+ if (typeof r.slug === 'string') return r.slug
199
+ return String(index)
200
+ },
201
+ })
202
+
203
+ const tableRows = table.getRowModel().rows
204
+ const tableRowCount = tableRows.length
205
+
206
+ // Reset scroll to top when sorting changes — without this, the user is
207
+ // staring at a different slice of a freshly-sorted dataset.
208
+ const scrollerRef = useRef<HTMLDivElement | null>(null)
209
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — scroll resets on sort change only
210
+ useEffect(() => {
211
+ scrollerRef.current?.scrollTo({ top: 0 })
212
+ }, [sorting])
213
+
214
+ // The sticky header lives inside the scroller, ABOVE the row list, so the
215
+ // list starts at an offset (the header's height, plus any `bodyBefore`).
216
+ // Feed that offset to the virtualizer as `scrollMargin` so its visible
217
+ // window math accounts for it; render rows at `start - scrollMargin`.
218
+ const listRef = useRef<HTMLDivElement | null>(null)
219
+ const [listOffset, setListOffset] = useState(0)
220
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure every render; cheap offsetTop read, guarded setState
221
+ useLayoutEffect(() => {
222
+ const top = listRef.current?.offsetTop ?? 0
223
+ setListOffset((prev) => (prev === top ? prev : top))
224
+ })
225
+
226
+ const virtualizer = useVirtualizer({
227
+ count: tableRowCount,
228
+ getScrollElement: () => scrollerRef.current,
229
+ estimateSize: () => rowHeight,
230
+ overscan,
231
+ scrollMargin: listOffset,
232
+ })
233
+
234
+ const sentinelRef = useInfiniteScrollSentinel<HTMLDivElement>({
235
+ onIntersect: () => {
236
+ if (query.hasNextPage && !query.isFetchingNextPage && !query.isLoading) {
237
+ query.fetchNextPage()
238
+ }
239
+ },
240
+ rootMargin: '200px',
241
+ root: scrollerRef.current,
242
+ enabled: !!query.hasNextPage,
243
+ })
244
+
245
+ const totalColumnWidth = useMemo(
246
+ () => columns.reduce((sum, c) => sum + (c.size ?? 140), 0),
247
+ [columns],
248
+ )
249
+
250
+ // Single global pragmatic-dnd monitor for column reorder. We mirror the
251
+ // entity-designer pattern: read the latest columns + handler via refs so
252
+ // the monitor doesn't re-register on every render. Gated on
253
+ // `enableColumnReordering` so it's a no-op when the feature is off.
254
+ const columnsRef = useRef(columns)
255
+ columnsRef.current = columns
256
+ const onReorderRef = useRef(onReorderColumn)
257
+ onReorderRef.current = onReorderColumn
258
+
259
+ useEffect(() => {
260
+ if (!enableColumnReordering) return
261
+ return monitorForElements({
262
+ canMonitor: ({ source }) => isColumnDragData(source.data),
263
+ onDrop: ({ source, location }) => {
264
+ const target = location.current.dropTargets[0]
265
+ if (!target) return
266
+ if (!isColumnDragData(source.data) || !isColumnDragData(target.data)) return
267
+ if (source.data.columnId === target.data.columnId) return
268
+
269
+ const ids = columnsRef.current.map((c) => c.id).filter((id): id is string => !!id)
270
+ const startIndex = ids.indexOf(source.data.columnId)
271
+ const indexOfTarget = ids.indexOf(target.data.columnId)
272
+ if (startIndex === -1 || indexOfTarget === -1) return
273
+
274
+ const closestEdge = extractClosestEdge(target.data)
275
+ const reordered = reorderWithEdge({
276
+ list: ids,
277
+ startIndex,
278
+ indexOfTarget,
279
+ closestEdgeOfTarget: closestEdge,
280
+ axis: 'horizontal',
281
+ })
282
+ if (reordered === ids) return
283
+
284
+ const sourceId = source.data.columnId
285
+ onReorderRef.current?.({ id: sourceId, toIndex: reordered.indexOf(sourceId) })
286
+ },
287
+ })
288
+ }, [enableColumnReordering])
289
+
290
+ const showEmpty =
291
+ !query.isLoading && !query.error && tableRowCount === 0 && !query.isFetchingNextPage
292
+ const showInitialLoading = query.isLoading && tableRowCount === 0
293
+ const showError = !!query.error && tableRowCount === 0
294
+ const showEndMarker =
295
+ !query.isLoading && !query.isFetchingNextPage && !query.hasNextPage && tableRowCount > 0
296
+
297
+ const totalSize = virtualizer.getTotalSize()
298
+ const virtualItems = virtualizer.getVirtualItems()
299
+
300
+ const headerGroups = table.getHeaderGroups()
301
+ const minPanelWidth = totalColumnWidth + 24
302
+
303
+ return (
304
+ <Table
305
+ {...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : {})}
306
+ variant={variant}
307
+ className={cn('flex-1 min-h-0', className)}
308
+ >
309
+ {/* Single both-axes scroll container. Horizontal wheel/trackpad scrolls
310
+ the columns; vertical scrolls the rows. The sticky header pins to the
311
+ top, and any `sticky left-0` gutter column freezes to the left. */}
312
+ <TableScroller ref={scrollerRef}>
313
+ {/* Content track — owns the horizontal min-width so the header + rows
314
+ scroll horizontally together as one unit. */}
315
+ <div className="flex flex-col" style={{ minWidth: minPanelWidth }}>
316
+ {/* Header row(s) — sticky-top so they pin while the rows scroll. */}
317
+ {headerGroups.map((group) => (
318
+ <TableHeader key={group.id} sticky>
319
+ {group.headers.map((header) => {
320
+ const meta = header.column.columnDef.meta as
321
+ | {
322
+ align?: 'left' | 'right' | 'center'
323
+ sticky?: 'left' | 'right' | false
324
+ cellClassName?: string
325
+ }
326
+ | undefined
327
+ const align = meta?.align ?? 'left'
328
+ const sticky = meta?.sticky ?? false
329
+ const headerSize = header.getSize() || undefined
330
+ const headerNode = header.isPlaceholder
331
+ ? null
332
+ : flexRender(header.column.columnDef.header, header.getContext())
333
+
334
+ const sorted = header.column.getIsSorted()
335
+ const direction: SortDirection = sorted === false ? null : sorted
336
+ const canSort = header.column.getCanSort()
337
+ const handleSort = header.column.getToggleSortingHandler()
338
+ const ariaLabel =
339
+ typeof header.column.columnDef.header === 'string'
340
+ ? header.column.columnDef.header
341
+ : header.column.id
342
+
343
+ return (
344
+ <TableHeaderCell
345
+ key={header.id}
346
+ align={align}
347
+ ariaLabel={ariaLabel}
348
+ draggable={enableColumnReordering && !!header.column.id}
349
+ {...(header.column.id ? { columnId: header.column.id } : {})}
350
+ {...(headerSize !== undefined ? { width: headerSize } : {})}
351
+ {...(sticky !== false ? { sticky } : {})}
352
+ {...(meta?.cellClassName ? { className: meta.cellClassName } : {})}
353
+ {...(canSort
354
+ ? {
355
+ sortDirection: direction,
356
+ onSortToggle: () => handleSort?.({} as never),
357
+ }
358
+ : {})}
359
+ {...(enableColumnRemoval && onRemoveColumn && header.column.id
360
+ ? { onRemove: () => onRemoveColumn(header.column.id) }
361
+ : {})}
362
+ >
363
+ {headerNode}
364
+ </TableHeaderCell>
365
+ )
366
+ })}
367
+ <TableHeaderSpacer />
368
+ </TableHeader>
369
+ ))}
370
+
371
+ {bodyBefore}
372
+
373
+ {showInitialLoading ? (
374
+ (loadingState ?? <DataTableSkeleton rowHeight={rowHeight} />)
375
+ ) : showError ? (
376
+ <div role="rowgroup">
377
+ {errorState ?? (query.error ? <DataTableError error={query.error} /> : null)}
378
+ </div>
379
+ ) : (
380
+ <div role="rowgroup">
381
+ {showEmpty ? (
382
+ (emptyState ?? <DataTableEmpty />)
383
+ ) : (
384
+ <div
385
+ ref={listRef}
386
+ role="presentation"
387
+ style={{ height: totalSize, position: 'relative', width: '100%' }}
388
+ >
389
+ {virtualItems.map((vRow) => {
390
+ const row = tableRows[vRow.index]
391
+ if (!row) return null
392
+ const rowStyle: CSSProperties = {
393
+ // Items' `start` includes the virtualizer's scrollMargin
394
+ // (the list's offset below the sticky header); subtract
395
+ // it to position relative to the list container.
396
+ transform: `translateY(${vRow.start - listOffset}px)`,
397
+ height: vRow.size,
398
+ }
399
+ return (
400
+ <DataRow
401
+ key={row.id}
402
+ row={row}
403
+ index={vRow.index}
404
+ zebra={zebra}
405
+ onRowClick={onRowClick}
406
+ style={rowStyle}
407
+ columnBorders={columnBorders}
408
+ {...(rowWrapper ? { rowWrapper } : {})}
409
+ />
410
+ )
411
+ })}
412
+ </div>
413
+ )}
414
+
415
+ {/* Sentinel — visually empty, positioned just above the loader/end
416
+ marker so it intersects ~200 px before the bottom. */}
417
+ {!showEmpty && (
418
+ <div ref={sentinelRef} aria-hidden="true" style={{ height: 1, width: '100%' }} />
419
+ )}
420
+
421
+ {query.isFetchingNextPage ? <LoaderRow /> : null}
422
+ {showEndMarker ? <EndMarker count={tableRowCount} /> : null}
423
+ </div>
424
+ )}
425
+
426
+ {bodyAfter}
427
+ </div>
428
+ </TableScroller>
429
+ </Table>
430
+ )
431
+ }
432
+
433
+ /* ---------- Row ---------------------------------------------------------- */
434
+
435
+ type DataRowProps<TRow> = {
436
+ row: Row<TRow>
437
+ index: number
438
+ zebra: boolean
439
+ onRowClick?: DataTableProps<TRow>['onRowClick']
440
+ style: CSSProperties
441
+ rowWrapper?: (row: Row<TRow>, cells: ReactNode) => ReactNode
442
+ columnBorders?: boolean
443
+ }
444
+
445
+ function DataRow<TRow>({
446
+ row,
447
+ index,
448
+ zebra,
449
+ onRowClick,
450
+ style,
451
+ rowWrapper,
452
+ columnBorders = false,
453
+ }: DataRowProps<TRow>) {
454
+ const isAlt = zebra && index % 2 === 1
455
+ const interactive = !!onRowClick
456
+
457
+ const cells = row.getVisibleCells().map((cell) => {
458
+ const meta = cell.column.columnDef.meta as
459
+ | {
460
+ align?: 'left' | 'right' | 'center'
461
+ mono?: boolean
462
+ ink?: boolean
463
+ sticky?: 'left' | 'right' | false
464
+ /** Extra classes merged onto the cell — e.g. to drop the default
465
+ * horizontal padding for a narrow fixed-width gutter column. */
466
+ cellClassName?: string
467
+ }
468
+ | undefined
469
+ const cellSize = cell.column.getSize() || undefined
470
+ const cellClassName = cn(
471
+ columnBorders ? 'border-r border-rule-2' : undefined,
472
+ meta?.cellClassName,
473
+ )
474
+ return (
475
+ <TableCell
476
+ key={cell.id}
477
+ {...(cellSize !== undefined ? { width: cellSize } : {})}
478
+ align={meta?.align ?? 'left'}
479
+ {...(meta?.mono !== undefined ? { mono: meta.mono } : {})}
480
+ {...(meta?.ink !== undefined ? { ink: meta.ink } : {})}
481
+ {...(meta?.sticky ? { sticky: meta.sticky } : {})}
482
+ {...(cellClassName ? { className: cellClassName } : {})}
483
+ >
484
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
485
+ </TableCell>
486
+ )
487
+ })
488
+
489
+ const body = rowWrapper ? rowWrapper(row, cells) : cells
490
+
491
+ return (
492
+ <TableRow
493
+ absolute
494
+ alt={isAlt}
495
+ interactive={interactive}
496
+ style={style}
497
+ data-row-id={row.id}
498
+ tabIndex={interactive ? 0 : undefined}
499
+ onClick={interactive ? (e) => onRowClick?.(row.original, e) : undefined}
500
+ onKeyDown={
501
+ interactive
502
+ ? (e) => {
503
+ if (e.key === 'Enter' || e.key === ' ') {
504
+ e.preventDefault()
505
+ onRowClick?.(row.original, e)
506
+ }
507
+ }
508
+ : undefined
509
+ }
510
+ >
511
+ {body}
512
+ </TableRow>
513
+ )
514
+ }
515
+
516
+ /* ---------- Loader & end marker ----------------------------------------- */
517
+
518
+ function LoaderRow() {
519
+ return (
520
+ <div
521
+ className={cn(
522
+ 'flex items-center justify-center gap-3',
523
+ 'h-8 font-mono text-[11px] tracking-[0.04em]',
524
+ 'text-ink-4',
525
+ )}
526
+ >
527
+ <span
528
+ aria-hidden="true"
529
+ className="relative inline-block h-[2px] w-24 overflow-hidden rounded-pill bg-rule"
530
+ >
531
+ <span className="absolute inset-0 w-[30%] rounded-pill bg-accent animate-[pt-slide_1.1s_ease-in-out_infinite]" />
532
+ </span>
533
+ <span>loading more</span>
534
+ <style>{`
535
+ @keyframes pt-slide {
536
+ 0% { transform: translateX(-110%); }
537
+ 100% { transform: translateX(360%); }
538
+ }
539
+ `}</style>
540
+ </div>
541
+ )
542
+ }
543
+
544
+ function EndMarker({ count }: { count: number }) {
545
+ return (
546
+ <div
547
+ className={cn(
548
+ 'flex items-center justify-center',
549
+ 'h-8 font-mono text-[10.5px] uppercase tracking-[0.08em]',
550
+ 'text-ink-4',
551
+ )}
552
+ >
553
+ end · {count.toLocaleString()} rows
554
+ </div>
555
+ )
556
+ }
@@ -0,0 +1,22 @@
1
+ export type {
2
+ BooleanCellProps,
3
+ DateCellProps,
4
+ DateTimeCellProps,
5
+ LinkCellProps,
6
+ TruncateCellProps,
7
+ } from './cells'
8
+ export { BooleanCell, DateCell, DateTimeCell, LinkCell, TruncateCell } from './cells'
9
+ export type { DataTableProps, InfiniteQueryLike } from './data-table'
10
+ export { DataTable } from './data-table'
11
+
12
+ export type { ActionsColumnOptions } from './make-actions-column'
13
+ export { makeActionsColumn } from './make-actions-column'
14
+ export type { DataTableEmptyProps } from './states/data-table-empty'
15
+ export { DataTableEmpty } from './states/data-table-empty'
16
+ export type { DataTableErrorProps } from './states/data-table-error'
17
+ export { DataTableError } from './states/data-table-error'
18
+ export type { DataTableSkeletonProps } from './states/data-table-skeleton'
19
+ export { DataTableSkeleton } from './states/data-table-skeleton'
20
+
21
+ export type { UseInfiniteScrollSentinelOptions } from './use-infinite-scroll-sentinel'
22
+ export { useInfiniteScrollSentinel } from './use-infinite-scroll-sentinel'
@@ -0,0 +1,30 @@
1
+ import type { ColumnDef } from '@tanstack/react-table'
2
+ import type { ReactNode } from 'react'
3
+
4
+ export type ActionsColumnOptions<TData> = {
5
+ id?: string
6
+ header?: string
7
+ width?: number
8
+ cell: (row: TData) => ReactNode
9
+ }
10
+
11
+ /**
12
+ * Convenience builder for a non-sortable, right-aligned actions column.
13
+ * Carries `meta.align: 'right'` so DataTable renders the header/cell
14
+ * end-aligned even though the column has no value to sort by.
15
+ */
16
+ export function makeActionsColumn<TData>({
17
+ id = 'actions',
18
+ header = '',
19
+ width = 80,
20
+ cell,
21
+ }: ActionsColumnOptions<TData>): ColumnDef<TData> {
22
+ return {
23
+ id,
24
+ header,
25
+ size: width,
26
+ enableSorting: false,
27
+ meta: { align: 'right' as const },
28
+ cell: ({ row }) => cell(row.original),
29
+ }
30
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { cn } from '../../../lib/cn'
4
+
5
+ export type DataTableEmptyProps = {
6
+ title?: string
7
+ message?: string
8
+ icon?: ReactNode
9
+ action?: ReactNode
10
+ className?: string
11
+ }
12
+
13
+ /**
14
+ * Empty state shown when the query returns zero rows.
15
+ * Mirrors the design's `pt-empty` block.
16
+ */
17
+ export function DataTableEmpty({
18
+ title = 'No results',
19
+ message,
20
+ icon,
21
+ action,
22
+ className,
23
+ }: DataTableEmptyProps) {
24
+ return (
25
+ <div
26
+ role="status"
27
+ className={cn(
28
+ 'flex flex-col items-center justify-center gap-2',
29
+ 'px-5 py-16 text-center',
30
+ 'font-sans text-[13px] text-ink-3',
31
+ className,
32
+ )}
33
+ >
34
+ {icon ? <div className="text-ink-4">{icon}</div> : null}
35
+ <div className="text-ink text-[14px]">{title}</div>
36
+ {message ? <div className="text-ink-3 max-w-[36ch]">{message}</div> : null}
37
+ {action ? <div className="mt-3">{action}</div> : null}
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,30 @@
1
+ import { cn } from '../../../lib/cn'
2
+ import { Button } from '../../button'
3
+
4
+ export type DataTableErrorProps = {
5
+ error: Error
6
+ onRetry?: () => void
7
+ className?: string
8
+ }
9
+
10
+ export function DataTableError({ error, onRetry, className }: DataTableErrorProps) {
11
+ return (
12
+ <div
13
+ role="alert"
14
+ className={cn(
15
+ 'flex flex-col items-center justify-center gap-3',
16
+ 'px-5 py-16 text-center',
17
+ 'font-sans text-[13px] text-ink-3',
18
+ className,
19
+ )}
20
+ >
21
+ <div className="text-danger text-[14px]">Something went wrong</div>
22
+ <div className="text-ink-3 max-w-[48ch] font-mono text-[12px]">{error.message}</div>
23
+ {onRetry ? (
24
+ <Button variant="ghost" size="sm" onPress={onRetry}>
25
+ Retry
26
+ </Button>
27
+ ) : null}
28
+ </div>
29
+ )
30
+ }