@scalepad/ui 0.1.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 (273) hide show
  1. package/.ai/rules/date-handling.md +39 -0
  2. package/.ai/rules/figma-design-system.md +372 -0
  3. package/.ai/rules/figma-lm-design-system-keys.md +680 -0
  4. package/.ai/rules/file-extensions.md +13 -0
  5. package/.ai/rules/modal-confirmation-mutation.md +56 -0
  6. package/.ai/rules/react-hooks.md +29 -0
  7. package/.ai/rules/styling.md +83 -0
  8. package/AGENTS.md +37 -0
  9. package/README.md +125 -0
  10. package/figma.config.json +9 -0
  11. package/package.json +127 -0
  12. package/scripts/install-ai-rules.mjs +136 -0
  13. package/src/ThemeProvider.tsx +57 -0
  14. package/src/charts.ts +32 -0
  15. package/src/components/ActionCard/ActionCard.css.ts +60 -0
  16. package/src/components/ActionCard/ActionCard.tsx +154 -0
  17. package/src/components/ActionCard/index.ts +2 -0
  18. package/src/components/Anchor/Anchor.tsx +47 -0
  19. package/src/components/Anchor/index.ts +2 -0
  20. package/src/components/AppliedFiltersManagerBar/AppliedFiltersManagerBar.tsx +105 -0
  21. package/src/components/AppliedFiltersManagerBar/FilterBadge.css.ts +23 -0
  22. package/src/components/AppliedFiltersManagerBar/FilterBadge.tsx +50 -0
  23. package/src/components/AppliedFiltersManagerBar/index.ts +5 -0
  24. package/src/components/Badge/Badge.css.ts +72 -0
  25. package/src/components/Badge/Badge.figma.tsx +43 -0
  26. package/src/components/Badge/Badge.tsx +159 -0
  27. package/src/components/Badge/index.ts +2 -0
  28. package/src/components/BreadCrumb/BreadCrumb.tsx +62 -0
  29. package/src/components/BreadCrumb/index.ts +2 -0
  30. package/src/components/BulkActionBar/BulkActionBar.css.ts +26 -0
  31. package/src/components/BulkActionBar/BulkActionBar.tsx +164 -0
  32. package/src/components/BulkActionBar/index.ts +2 -0
  33. package/src/components/Button/Button.css.ts +272 -0
  34. package/src/components/Button/Button.figma.tsx +74 -0
  35. package/src/components/Button/Button.tsx +84 -0
  36. package/src/components/Button/index.ts +2 -0
  37. package/src/components/Charts/ChartTooltip.figma.tsx +33 -0
  38. package/src/components/Charts/ChartTooltip.tsx +101 -0
  39. package/src/components/Charts/MiniBarSparkline.tsx +75 -0
  40. package/src/components/Charts/StackedPatternBarChart.tsx +494 -0
  41. package/src/components/Charts/TrendAreaChart.css.ts +23 -0
  42. package/src/components/Charts/TrendAreaChart.tsx +210 -0
  43. package/src/components/Charts/index.ts +12 -0
  44. package/src/components/CodePanel/CodePanel.css.ts +113 -0
  45. package/src/components/CodePanel/CodePanel.tsx +121 -0
  46. package/src/components/CodePanel/index.ts +2 -0
  47. package/src/components/CommentComposer/CommentComposer.css.ts +60 -0
  48. package/src/components/CommentComposer/CommentComposer.tsx +181 -0
  49. package/src/components/CommentComposer/index.ts +2 -0
  50. package/src/components/ConfirmationModal/ConfirmationModal.tsx +149 -0
  51. package/src/components/ConfirmationModal/index.ts +2 -0
  52. package/src/components/ConfirmationTooltip/ConfirmationTooltip.tsx +132 -0
  53. package/src/components/ConfirmationTooltip/index.ts +2 -0
  54. package/src/components/DataDialog.figma.tsx +33 -0
  55. package/src/components/DataDialog.tsx +46 -0
  56. package/src/components/DataTable/DataTable.tsx +1042 -0
  57. package/src/components/DataTable/RowExpandToggle.tsx +105 -0
  58. package/src/components/DataTable/RowGroupHeader.tsx +190 -0
  59. package/src/components/DataTable/createActionsColumn.tsx +86 -0
  60. package/src/components/DataTable/index.ts +25 -0
  61. package/src/components/DatePicker/CustomRangePicker.tsx +59 -0
  62. package/src/components/DatePicker/DateInput.tsx +329 -0
  63. package/src/components/DatePicker/DateNavigator.tsx +486 -0
  64. package/src/components/DatePicker/DatePicker.tsx +242 -0
  65. package/src/components/DatePicker/MonthlyRangePicker.tsx +231 -0
  66. package/src/components/DatePicker/QuarterlyRangePicker.tsx +224 -0
  67. package/src/components/DatePicker/QuickPicksSidebar.tsx +242 -0
  68. package/src/components/DatePicker/YearlyRangePicker.tsx +171 -0
  69. package/src/components/DatePicker/index.ts +7 -0
  70. package/src/components/DatePicker/types.ts +12 -0
  71. package/src/components/DesignSystemPrimitives/FluidGrid.tsx +44 -0
  72. package/src/components/DesignSystemPrimitives/InteractivePrimitives.tsx +177 -0
  73. package/src/components/DesignSystemPrimitives/LayoutPrimitives.tsx +220 -0
  74. package/src/components/DesignSystemPrimitives/LayoutPrimitives.types.tsx +15 -0
  75. package/src/components/DesignSystemPrimitives/SurfacePrimitives.tsx +46 -0
  76. package/src/components/DesignSystemPrimitives/index.ts +55 -0
  77. package/src/components/Details/Details.css.ts +74 -0
  78. package/src/components/Details/Details.tsx +140 -0
  79. package/src/components/Details/index.ts +2 -0
  80. package/src/components/DownloadCard/DownloadCard.css.ts +22 -0
  81. package/src/components/DownloadCard/DownloadCard.tsx +63 -0
  82. package/src/components/DownloadCard/index.ts +2 -0
  83. package/src/components/Drawer/Drawer.css.ts +32 -0
  84. package/src/components/Drawer/Drawer.tsx +236 -0
  85. package/src/components/Drawer/hooks/useDetailDrawer.ts +61 -0
  86. package/src/components/Drawer/hooks/useDetailDrawerNavigation.ts +125 -0
  87. package/src/components/Drawer/hooks/useDetailDrawerNavigationContext.ts +66 -0
  88. package/src/components/EditableRichText/EditableRichText.css.ts +72 -0
  89. package/src/components/EditableRichText/EditableRichText.tsx +324 -0
  90. package/src/components/EditableRichText/index.ts +2 -0
  91. package/src/components/EditableSelect/EditableSelect.css.ts +62 -0
  92. package/src/components/EditableSelect/EditableSelect.tsx +224 -0
  93. package/src/components/EditableSelect/index.ts +2 -0
  94. package/src/components/EditableText/EditableText.tsx +377 -0
  95. package/src/components/EditableText/index.ts +2 -0
  96. package/src/components/EmptyState/EmptyState.figma.tsx +33 -0
  97. package/src/components/EmptyState/EmptyState.tsx +230 -0
  98. package/src/components/EmptyState/index.ts +2 -0
  99. package/src/components/ErrorBoundary.tsx +135 -0
  100. package/src/components/ErrorState/ErrorState.tsx +197 -0
  101. package/src/components/ErrorState/index.ts +2 -0
  102. package/src/components/FeatureCard.tsx +42 -0
  103. package/src/components/FilterMenu/FilterMenu.figma.tsx +30 -0
  104. package/src/components/FilterMenu/FilterMenu.tsx +198 -0
  105. package/src/components/FilterMenu/FilterSubMenuTypes/BooleanFilterSubmenu.tsx +46 -0
  106. package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +239 -0
  107. package/src/components/FilterMenu/FilterSubMenuTypes/index.ts +8 -0
  108. package/src/components/FilterMenu/defaultFilterSchemas.ts +63 -0
  109. package/src/components/FilterMenu/helpers.ts +115 -0
  110. package/src/components/FilterMenu/index.ts +35 -0
  111. package/src/components/FilterMenu/types.ts +101 -0
  112. package/src/components/IconButton/IconButton.css.ts +272 -0
  113. package/src/components/IconButton/IconButton.figma.tsx +47 -0
  114. package/src/components/IconButton/IconButton.tsx +72 -0
  115. package/src/components/IconButton/README.md +230 -0
  116. package/src/components/IconButton/index.ts +2 -0
  117. package/src/components/InfiniteScrollSentinel.tsx +86 -0
  118. package/src/components/InfiniteScrollTrigger.tsx +78 -0
  119. package/src/components/InfoCard.figma.tsx +47 -0
  120. package/src/components/InfoCard.tsx +216 -0
  121. package/src/components/KbdHint/KbdHint.tsx +23 -0
  122. package/src/components/KbdHint/index.ts +2 -0
  123. package/src/components/LabeledField/LabeledField.tsx +21 -0
  124. package/src/components/LabeledField/index.ts +2 -0
  125. package/src/components/LookupSelect/LookupSelect.css.ts +149 -0
  126. package/src/components/LookupSelect/LookupSelect.tsx +325 -0
  127. package/src/components/LookupSelect/index.ts +2 -0
  128. package/src/components/Menu/Menu.css.ts +89 -0
  129. package/src/components/Menu/Menu.tsx +105 -0
  130. package/src/components/Menu/index.ts +2 -0
  131. package/src/components/MessageBox/MessageBox.tsx +168 -0
  132. package/src/components/MessageBox/index.ts +2 -0
  133. package/src/components/MetricDisplay/MetricDisplay.tsx +55 -0
  134. package/src/components/MetricDisplay/index.ts +1 -0
  135. package/src/components/MultiSelect/MultiSelect.tsx +278 -0
  136. package/src/components/MultiSelect/index.ts +2 -0
  137. package/src/components/Notifications/Notifications.tsx +12 -0
  138. package/src/components/Notifications/README.md +93 -0
  139. package/src/components/Notifications/index.ts +4 -0
  140. package/src/components/Notifications/showToast.tsx +100 -0
  141. package/src/components/PropertyRow/PropertyRow.tsx +96 -0
  142. package/src/components/PropertyRow/index.ts +2 -0
  143. package/src/components/RadioTile/RadioTile.tsx +253 -0
  144. package/src/components/RadioTile/index.ts +2 -0
  145. package/src/components/RichText/FormattingToolbar.css.ts +69 -0
  146. package/src/components/RichText/FormattingToolbar.tsx +112 -0
  147. package/src/components/RichText/RichTextInline.css.ts +54 -0
  148. package/src/components/RichText/RichTextInline.tsx +318 -0
  149. package/src/components/RichText/formattingCommands.ts +181 -0
  150. package/src/components/RichText/formattingTypes.ts +34 -0
  151. package/src/components/RichText/index.ts +49 -0
  152. package/src/components/RichText/richTextExtensions.ts +111 -0
  153. package/src/components/RichText/richTextHelpers.ts +65 -0
  154. package/src/components/RichText/richTextImage.ts +253 -0
  155. package/src/components/RichText/richTextImageHandlers.ts +244 -0
  156. package/src/components/RichText/richTextProse.css.ts +261 -0
  157. package/src/components/RichTextEditor/RichTextEditor.css.ts +82 -0
  158. package/src/components/RichTextEditor/RichTextEditor.tsx +204 -0
  159. package/src/components/RichTextEditor/index.ts +2 -0
  160. package/src/components/RichTextView/RichTextView.css.ts +11 -0
  161. package/src/components/RichTextView/RichTextView.tsx +114 -0
  162. package/src/components/RichTextView/index.ts +2 -0
  163. package/src/components/Schedule/Schedule.tsx +35 -0
  164. package/src/components/SchedulePicker/SchedulePicker.css.ts +42 -0
  165. package/src/components/SchedulePicker/SchedulePicker.tsx +130 -0
  166. package/src/components/SchedulePicker/index.ts +2 -0
  167. package/src/components/SearchableList/types.ts +30 -0
  168. package/src/components/SearchableSubMenu/SearchableSubMenu.css.ts +25 -0
  169. package/src/components/SearchableSubMenu/SearchableSubMenu.tsx +139 -0
  170. package/src/components/SearchableSubMenu/index.ts +2 -0
  171. package/src/components/Select/README.md +114 -0
  172. package/src/components/Select/Select.css.ts +110 -0
  173. package/src/components/Select/Select.tsx +133 -0
  174. package/src/components/Select/index.ts +2 -0
  175. package/src/components/SelectCreatable/SelectCreatable.css.ts +16 -0
  176. package/src/components/SelectCreatable/SelectCreatable.tsx +203 -0
  177. package/src/components/SelectCreatable/index.ts +2 -0
  178. package/src/components/SettingsCard/SettingsCard.tsx +98 -0
  179. package/src/components/SettingsCard/index.ts +2 -0
  180. package/src/components/Sidebar/Sidebar.css.ts +91 -0
  181. package/src/components/Sidebar/Sidebar.tsx +129 -0
  182. package/src/components/Sidebar/index.ts +5 -0
  183. package/src/components/SimpleList/SimpleList.css.ts +12 -0
  184. package/src/components/SimpleList/SimpleList.tsx +44 -0
  185. package/src/components/SimpleList/index.ts +2 -0
  186. package/src/components/SimpleTable/SimpleTable.tsx +296 -0
  187. package/src/components/SimpleTable/index.ts +2 -0
  188. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.css.ts +62 -0
  189. package/src/components/SlashRichTextEditor/SelectionBubbleMenu.tsx +85 -0
  190. package/src/components/SlashRichTextEditor/SlashCommandMenu.css.ts +124 -0
  191. package/src/components/SlashRichTextEditor/SlashCommandMenu.tsx +168 -0
  192. package/src/components/SlashRichTextEditor/SlashRichTextEditor.css.ts +81 -0
  193. package/src/components/SlashRichTextEditor/SlashRichTextEditor.tsx +538 -0
  194. package/src/components/SlashRichTextEditor/SlashSuggestionExtension.ts +48 -0
  195. package/src/components/SlashRichTextEditor/index.ts +13 -0
  196. package/src/components/SlashRichTextEditor/types.ts +48 -0
  197. package/src/components/StatCard/StatCard.css.ts +70 -0
  198. package/src/components/StatCard/StatCard.tsx +201 -0
  199. package/src/components/StatCard/index.ts +1 -0
  200. package/src/components/StatusBadge/StatusBadge.tsx +70 -0
  201. package/src/components/StatusBadge/index.ts +2 -0
  202. package/src/components/StatusIndicator/StatusIndicator.tsx +67 -0
  203. package/src/components/StatusIndicator/index.ts +6 -0
  204. package/src/components/SubNavigation/SubNavigation.css.ts +72 -0
  205. package/src/components/SubNavigation/SubNavigation.tsx +104 -0
  206. package/src/components/SubNavigation/index.ts +2 -0
  207. package/src/components/SuspenseLoader.tsx +22 -0
  208. package/src/components/Table/SortableColumnHeader.tsx +99 -0
  209. package/src/components/Table/TableSkeletonRows.figma.tsx +22 -0
  210. package/src/components/Table/TableSkeletonRows.tsx +113 -0
  211. package/src/components/Table/index.ts +9 -0
  212. package/src/components/TableActionsMenu.tsx +58 -0
  213. package/src/components/TableCard.tsx +29 -0
  214. package/src/components/TableContainer/TableContainer.tsx +86 -0
  215. package/src/components/TableContainer/index.ts +2 -0
  216. package/src/components/TableControlBar/TableControlBar.tsx +156 -0
  217. package/src/components/TableControlBar/TableSelectionButton.tsx +57 -0
  218. package/src/components/TableControlBar/index.ts +13 -0
  219. package/src/components/TableControlBar/useTableControlBar.tsx +314 -0
  220. package/src/components/TableSelection/TableSelection.tsx +43 -0
  221. package/src/components/TableSelection/index.ts +5 -0
  222. package/src/components/Tabs/README.md +76 -0
  223. package/src/components/Tabs/Tabs.css.ts +54 -0
  224. package/src/components/Tabs/Tabs.figma.tsx +47 -0
  225. package/src/components/Tabs/Tabs.tsx +96 -0
  226. package/src/components/Tabs/index.ts +8 -0
  227. package/src/components/TextInput/README.md +98 -0
  228. package/src/components/TextInput/SearchTextInput.figma.tsx +22 -0
  229. package/src/components/TextInput/SearchTextInput.tsx +150 -0
  230. package/src/components/TextInput/TextInput.figma.tsx +44 -0
  231. package/src/components/TextInput/TextInput.tsx +42 -0
  232. package/src/components/TextInput/index.ts +4 -0
  233. package/src/components/ThemeSwitcher.figma.tsx +28 -0
  234. package/src/components/ThemeSwitcher.tsx +69 -0
  235. package/src/components/TrendBadge/TrendBadge.tsx +76 -0
  236. package/src/components/TrendBadge/index.ts +2 -0
  237. package/src/components/TruncatedText.tsx +115 -0
  238. package/src/components/Typography/Text.tsx +74 -0
  239. package/src/components/Typography/Title.tsx +100 -0
  240. package/src/components/Typography/index.ts +4 -0
  241. package/src/geist-fonts.ts +48 -0
  242. package/src/hooks/index.ts +31 -0
  243. package/src/hooks/useFilters.ts +152 -0
  244. package/src/hooks/useInfiniteScroll.ts +62 -0
  245. package/src/hooks/usePlatform.ts +33 -0
  246. package/src/hooks/useServerTable.ts +495 -0
  247. package/src/hooks/useTableSelection.ts +102 -0
  248. package/src/hooks/useTableSort.ts +259 -0
  249. package/src/index.ts +483 -0
  250. package/src/mantine.ts +25 -0
  251. package/src/theme/mantineVars.ts +12 -0
  252. package/src/theme/themeContract.css.ts +131 -0
  253. package/src/theme/themeVars.ts +31 -0
  254. package/src/theme.ts +168 -0
  255. package/src/tokens/color-types.ts +107 -0
  256. package/src/tokens/colors.ts +243 -0
  257. package/src/tokens/index.ts +14 -0
  258. package/src/tokens/radius.ts +17 -0
  259. package/src/tokens/semantic-colors.ts +224 -0
  260. package/src/tokens/semantic-tokens-css.ts +53 -0
  261. package/src/tokens/shadows.ts +11 -0
  262. package/src/tokens/spacing.ts +20 -0
  263. package/src/tokens/text-styles.ts +179 -0
  264. package/src/tokens/typography.ts +40 -0
  265. package/src/tokens/zIndex.ts +27 -0
  266. package/src/types/mantine-theme.d.ts +17 -0
  267. package/src/types/tanstack-table.d.ts +22 -0
  268. package/src/utils/avatar.ts +150 -0
  269. package/src/utils/chartHelpers.ts +53 -0
  270. package/src/utils/color-props.ts +77 -0
  271. package/src/utils/createDesignComponent.tsx +104 -0
  272. package/src/utils/nestFlatRows.ts +111 -0
  273. package/src/utils/withStaticComponents.ts +6 -0
@@ -0,0 +1,495 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ import {
4
+ useInfiniteQuery,
5
+ type InfiniteData,
6
+ type UseInfiniteQueryOptions,
7
+ } from '@tanstack/react-query';
8
+ import {
9
+ type ExpandedState,
10
+ type VisibilityState,
11
+ getCoreRowModel,
12
+ getExpandedRowModel,
13
+ useReactTable,
14
+ type ColumnDef,
15
+ type OnChangeFn,
16
+ type RowSelectionState,
17
+ type SortingState,
18
+ type Table,
19
+ } from '@tanstack/react-table';
20
+
21
+ import { useStableValue } from '@scalepad/ui-utils/hooks';
22
+
23
+ import { nestFlatRows, type WithSubRows } from '../utils/nestFlatRows';
24
+
25
+ import type { InfiniteScrollState } from '../components/InfiniteScrollTrigger';
26
+
27
+ /**
28
+ * Generic list response type for paginated API endpoints.
29
+ * Shared contract between useServerTable and query options functions.
30
+ */
31
+ export interface ListResponse<TData> {
32
+ data: TData[];
33
+ nextCursor?: string | null;
34
+ }
35
+
36
+ /**
37
+ * Options for the useServerTable hook
38
+ */
39
+ export interface UseServerTableOptions<TData, TFilters extends object> {
40
+ /**
41
+ * Column definitions for TanStack Table
42
+ */
43
+ columns: ColumnDef<TData, unknown>[];
44
+ /**
45
+ * Function that returns infinite query options given filters
46
+ * The filters will include converted sort params (sort_by, sort_order)
47
+ */
48
+ queryOptions: (
49
+ filters: TFilters,
50
+ ) => UseInfiniteQueryOptions<
51
+ ListResponse<TData>,
52
+ Error,
53
+ InfiniteData<ListResponse<TData>>,
54
+ readonly unknown[],
55
+ string
56
+ >;
57
+ /**
58
+ * Filter params to pass to the query (search, view mode, date range, etc.)
59
+ * Sort params will be added automatically based on table sorting state
60
+ */
61
+ filters: TFilters;
62
+ /**
63
+ * Optional function to extract row ID from data
64
+ * @default (row) => row.id
65
+ */
66
+ getRowId?: (row: TData) => string;
67
+ /**
68
+ * Optional field name mapping from TanStack Table column IDs to API sort field names
69
+ * @example { app_name: 'name', unique_users: 'users_count' }
70
+ */
71
+ sortFieldMapping?: Record<string, string>;
72
+ /**
73
+ * Enable row selection
74
+ * @default false
75
+ */
76
+ enableRowSelection?: boolean;
77
+ /**
78
+ * Initial sorting state (e.g. [{ id: 'canonical_name', desc: false }]).
79
+ * Use when the API expects a default sort on first load.
80
+ */
81
+ initialSorting?: SortingState;
82
+ /**
83
+ * Initial column visibility map keyed by column ID.
84
+ * Omitted IDs default to visible.
85
+ * @example { approval_status: false, discovered_date: false }
86
+ */
87
+ initialColumnVisibility?: VisibilityState;
88
+ /**
89
+ * Opt in to tree rows (row grouping via expansion).
90
+ *
91
+ * Exactly one of `getSubRows` or `getParentId` must be provided:
92
+ * - `getSubRows`: server already returns nested data; the hook uses it
93
+ * directly.
94
+ * - `getParentId`: server returns a flat list with a parent reference on
95
+ * each row; the hook nests it via {@link nestFlatRows} before handing it
96
+ * to TanStack Table.
97
+ *
98
+ * Expand state can be either uncontrolled (seeded via `initialExpanded`)
99
+ * or controlled (`expanded` + `onExpandedChange`). Default is all
100
+ * collapsed.
101
+ */
102
+ tree?: {
103
+ /**
104
+ * Return nested child rows for a given row. Mutually exclusive with
105
+ * `getParentId`.
106
+ */
107
+ getSubRows?: (row: TData) => TData[] | undefined;
108
+ /**
109
+ * Return the `id` of this row's parent (or nullish for a root). Mutually
110
+ * exclusive with `getSubRows`. When present the hook nests the flat
111
+ * result of the infinite query via `nestFlatRows`.
112
+ */
113
+ getParentId?: (row: TData) => string | null | undefined;
114
+ /**
115
+ * How to handle rows that reference a parentId not present in the data.
116
+ * Only honored when using `getParentId`.
117
+ * @default "throw"
118
+ */
119
+ onOrphan?: 'throw' | 'drop';
120
+ /**
121
+ * Initial expand state (uncontrolled mode only).
122
+ * - `"none"` (default): everything collapsed.
123
+ * - `"all"`: every row with children starts expanded.
124
+ * - An explicit `ExpandedState` for fine-grained control.
125
+ */
126
+ initialExpanded?: 'all' | 'none' | ExpandedState;
127
+ /**
128
+ * Controlled expand state. When provided the hook does not maintain its
129
+ * own state; pair with `onExpandedChange`.
130
+ */
131
+ expanded?: ExpandedState;
132
+ /**
133
+ * Called when the table wants to change expanded state. Required in
134
+ * controlled mode.
135
+ */
136
+ onExpandedChange?: OnChangeFn<ExpandedState>;
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Return type for the useServerTable hook
142
+ */
143
+ export interface UseServerTableReturn<TData> {
144
+ /**
145
+ * TanStack Table instance (use for rendering)
146
+ */
147
+ table: Table<TData>;
148
+ /**
149
+ * Flattened data array from all pages
150
+ */
151
+ data: TData[];
152
+ /**
153
+ * True ONLY on first load (no cached data) -> show skeleton
154
+ */
155
+ isInitialLoading: boolean;
156
+ /**
157
+ * True when data exists but refetching (sort/filter change) -> show subtle indicator
158
+ */
159
+ isRefetching: boolean;
160
+ /**
161
+ * True when query failed
162
+ */
163
+ isError: boolean;
164
+ /**
165
+ * True when the data is empty (no results) and not loading
166
+ * Use this to disable controls or show "no data" UI
167
+ */
168
+ hasNoData: boolean;
169
+ /**
170
+ * Pagination state for InfiniteScrollTrigger
171
+ */
172
+ pagination: InfiniteScrollState;
173
+ /**
174
+ * Selected row IDs (when enableRowSelection is true)
175
+ */
176
+ selectedIds: Set<string>;
177
+ /**
178
+ * Clear row selection
179
+ */
180
+ clearSelection: () => void;
181
+ /**
182
+ * Set table sorting state (e.g. for external sort controls like a grid sort dropdown).
183
+ * Uses the same signature as TanStack Table's onSortingChange.
184
+ */
185
+ setSorting: OnChangeFn<SortingState>;
186
+ /**
187
+ * Current expand state. Only meaningful when `tree` was configured.
188
+ * In uncontrolled mode this reflects the hook's internal state; in
189
+ * controlled mode it mirrors the `tree.expanded` prop.
190
+ */
191
+ expanded: ExpandedState;
192
+ /**
193
+ * Programmatically set expand state (e.g. "expand all" / "collapse all"
194
+ * toolbar buttons). A no-op when `tree` was not configured.
195
+ */
196
+ setExpanded: OnChangeFn<ExpandedState>;
197
+ }
198
+
199
+ /**
200
+ * Flatten infinite query pages into a single array
201
+ */
202
+ function flattenInfinitePages<TData>(
203
+ data: InfiniteData<ListResponse<TData>>,
204
+ ): TData[] {
205
+ return data.pages.flatMap(page => page.data);
206
+ }
207
+
208
+ /**
209
+ * Convert TanStack Table sorting state to API query params
210
+ * @example [{ id: 'app_name', desc: true }] -> { sort_by: 'app_name', sort_order: 'desc' }
211
+ */
212
+ function convertSortingToApiParams(
213
+ sorting: SortingState,
214
+ sortFieldMapping?: Record<string, string>,
215
+ ): { sort_by?: string; sort_order?: 'asc' | 'desc' } {
216
+ if (sorting.length === 0) {
217
+ return { sort_by: undefined, sort_order: undefined };
218
+ }
219
+
220
+ // SortingState is an array (TanStack Table supports multi-column sorting),
221
+ // but our API only accepts a single sort_by/sort_order pair, so we use the first entry.
222
+ const sortState = sorting[0];
223
+ const apiFieldName = sortFieldMapping?.[sortState.id] ?? sortState.id;
224
+
225
+ return {
226
+ sort_by: apiFieldName,
227
+ sort_order: sortState.desc ? 'desc' : 'asc',
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Hook for server-side table with TanStack Table + useInfiniteQuery
233
+ *
234
+ * Combines TanStack Table's state management with TanStack Query's data fetching
235
+ * to create a seamless server-side table experience with:
236
+ * - Declarative column definitions
237
+ * - Automatic sort state -> API params conversion
238
+ * - Clean loading state semantics (isInitialLoading vs isRefetching)
239
+ * - Smooth transitions with placeholderData
240
+ * - Built-in row selection (optional)
241
+ *
242
+ * @example Basic usage
243
+ * ```tsx
244
+ * const { table, ...states } = useServerTable({
245
+ * columns: appColumns,
246
+ * queryOptions: (filters) => appsInfiniteQueryOptions(clientId, filters),
247
+ * filters: { granularity, date, q: search },
248
+ * });
249
+ *
250
+ * <DataTable {...states} table={table} emptyState={...} />
251
+ * ```
252
+ *
253
+ * @example With row selection
254
+ * ```tsx
255
+ * const { table, selectedIds, clearSelection, ...states } = useServerTable({
256
+ * columns: userColumns,
257
+ * queryOptions: (filters) => usersInfiniteQueryOptions(clientId, filters),
258
+ * filters: { q: search },
259
+ * enableRowSelection: true,
260
+ * });
261
+ * ```
262
+ */
263
+ export function useServerTable<TData, TFilters extends object>({
264
+ columns,
265
+ queryOptions,
266
+ filters,
267
+ getRowId = row => String((row as { id: string | number }).id),
268
+ sortFieldMapping,
269
+ enableRowSelection = false,
270
+ initialSorting,
271
+ initialColumnVisibility,
272
+ tree,
273
+ }: UseServerTableOptions<TData, TFilters>): UseServerTableReturn<TData> {
274
+ // TanStack Table state: sorting
275
+ const [sorting, setSorting] = useState<SortingState>(initialSorting ?? []);
276
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
277
+ initialColumnVisibility ?? {},
278
+ );
279
+
280
+ // TanStack Table state: row selection (optional)
281
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
282
+
283
+ // Dev-time validation: exactly one of getSubRows / getParentId must be set
284
+ // when tree is enabled. We don't guard on NODE_ENV here because this is
285
+ // cheap and the hook is only used inside React.
286
+ if (tree) {
287
+ const hasSub = !!tree.getSubRows;
288
+ const hasParent = !!tree.getParentId;
289
+ if (hasSub === hasParent) {
290
+ console.warn(
291
+ '[useServerTable] `tree` requires exactly one of `getSubRows` or `getParentId`.',
292
+ );
293
+ }
294
+ if (tree.expanded !== undefined && tree.onExpandedChange === undefined) {
295
+ console.warn(
296
+ '[useServerTable] `tree.expanded` was provided without `tree.onExpandedChange`; expand toggling will be a no-op.',
297
+ );
298
+ }
299
+ }
300
+
301
+ // TanStack Table state: expanded (only used when tree is enabled and
302
+ // consumer has not taken control). We always instantiate the state hook
303
+ // (rules of hooks) but only feed it into useReactTable when relevant.
304
+ // TanStack's `ExpandedState` supports `true` as a shorthand for
305
+ // "every expandable row is expanded", which is exactly what `'all'`
306
+ // means here.
307
+ const [internalExpanded, setInternalExpanded] = useState<ExpandedState>(
308
+ () => {
309
+ if (!tree) return {};
310
+ const initial = tree.initialExpanded;
311
+ if (initial === undefined || initial === 'none') return {};
312
+ if (initial === 'all') return true;
313
+ return initial;
314
+ },
315
+ );
316
+
317
+ // Stabilize filters object to prevent unnecessary refetches when parent components
318
+ // re-render with new filter object references but same values
319
+ const stableFilters = useStableValue(filters);
320
+
321
+ // Convert TanStack Table sorting to API params
322
+ const sortParams = useMemo(
323
+ () => convertSortingToApiParams(sorting, sortFieldMapping),
324
+ [sorting, sortFieldMapping],
325
+ );
326
+
327
+ // Merge sort params with user filters
328
+ const queryFilters = useMemo(
329
+ () => ({ ...stableFilters, ...sortParams }) as TFilters,
330
+ [stableFilters, sortParams],
331
+ );
332
+
333
+ // Fetch data with infinite query
334
+ const queryResult = useInfiniteQuery({
335
+ ...queryOptions(queryFilters),
336
+ // Keep previous data visible while fetching new sorted data
337
+ // This makes sorting feel instant instead of showing a skeleton
338
+ // CRITICAL: Don't use placeholderData if we don't have data yet for THIS query
339
+ // (data might exist from a previous query with different filters)
340
+ placeholderData: previousData => {
341
+ // If there's no previous data, don't use placeholder
342
+ if (!previousData) return undefined;
343
+ // Use placeholder to keep UI smooth during refetches
344
+ return previousData;
345
+ },
346
+ });
347
+
348
+ const {
349
+ data,
350
+ hasNextPage,
351
+ isFetchingNextPage,
352
+ fetchNextPage,
353
+ isLoading,
354
+ isError,
355
+ isFetching,
356
+ } = queryResult;
357
+
358
+ // Flatten pages into single array
359
+ const flattenedData = useMemo(
360
+ () => (data ? flattenInfinitePages(data) : []),
361
+ [data],
362
+ );
363
+
364
+ // When tree is enabled with `getParentId`, nest the flat result into a
365
+ // tree. When tree is enabled with `getSubRows`, the server already
366
+ // returns nested data and we pass it through untouched. When tree is
367
+ // disabled, pass the flat data through untouched.
368
+ const tableData = useMemo<TData[]>(() => {
369
+ if (!tree || !tree.getParentId) {
370
+ return flattenedData;
371
+ }
372
+ const nested = nestFlatRows(flattenedData, {
373
+ getId: getRowId,
374
+ getParentId: tree.getParentId,
375
+ onOrphan: tree.onOrphan ?? 'throw',
376
+ }) as (TData & WithSubRows<TData>)[];
377
+ return nested;
378
+ }, [flattenedData, tree, getRowId]);
379
+
380
+ // Pick the effective expand state + setter (controlled vs uncontrolled).
381
+ const expandedState: ExpandedState = tree
382
+ ? (tree.expanded ?? internalExpanded)
383
+ : {};
384
+ const onExpandedChange: OnChangeFn<ExpandedState> = tree
385
+ ? (tree.onExpandedChange ??
386
+ (setInternalExpanded as OnChangeFn<ExpandedState>))
387
+ : (setInternalExpanded as OnChangeFn<ExpandedState>);
388
+
389
+ // Resolve `getSubRows` for TanStack. Priority:
390
+ // 1. Consumer-provided `tree.getSubRows`.
391
+ // 2. When nesting via `getParentId`, read off the `subRows` field written
392
+ // by `nestFlatRows`.
393
+ // 3. Otherwise, undefined (flat table behavior).
394
+ const treeGetSubRows: ((row: TData) => TData[] | undefined) | undefined =
395
+ tree?.getSubRows ??
396
+ (tree?.getParentId
397
+ ? (row: TData) => (row as WithSubRows<TData>).subRows
398
+ : undefined);
399
+
400
+ // Derive clean loading states
401
+ const isInitialLoading = isLoading;
402
+ const isRefetching = !isLoading && isFetching;
403
+
404
+ // Derive initial column pinning from column meta (meta.pinned: 'left' | 'right')
405
+ const initialColumnPinning = useMemo(() => {
406
+ const left: string[] = [];
407
+ const right: string[] = [];
408
+ for (const col of columns) {
409
+ const id = (col as ColumnDef<TData>).id;
410
+ const pinned = (col as ColumnDef<TData>).meta as
411
+ | { pinned?: 'left' | 'right' }
412
+ | undefined;
413
+ if (typeof id === 'string' && pinned?.pinned === 'left') left.push(id);
414
+ if (typeof id === 'string' && pinned?.pinned === 'right') right.push(id);
415
+ }
416
+ return { left, right };
417
+ }, [columns]);
418
+
419
+ // Create TanStack Table instance
420
+ const table = useReactTable({
421
+ data: tableData,
422
+ columns,
423
+ getCoreRowModel: getCoreRowModel(),
424
+ // Always provide getRowId so row.id is a stable entity ID (used as React key),
425
+ // not the default array index which causes reconciliation bugs on sort/page changes.
426
+ getRowId,
427
+ // Server-side sorting
428
+ manualSorting: true,
429
+ // Enable column sizing - columns without explicit size will use default 150px
430
+ defaultColumn: {
431
+ size: 150,
432
+ minSize: 40,
433
+ maxSize: 500,
434
+ },
435
+ initialState: {
436
+ columnPinning: initialColumnPinning,
437
+ },
438
+ state: {
439
+ sorting,
440
+ columnVisibility,
441
+ ...(enableRowSelection && { rowSelection }),
442
+ ...(tree && { expanded: expandedState }),
443
+ },
444
+ onSortingChange: setSorting as OnChangeFn<SortingState>,
445
+ onColumnVisibilityChange:
446
+ setColumnVisibility as OnChangeFn<VisibilityState>,
447
+ ...(enableRowSelection && {
448
+ onRowSelectionChange: setRowSelection as OnChangeFn<RowSelectionState>,
449
+ enableRowSelection: true,
450
+ // Select descendants alongside a group row so checkbox + chevron can
451
+ // coexist meaningfully on grouped rows.
452
+ enableSubRowSelection: true,
453
+ }),
454
+ ...(tree && {
455
+ getSubRows: treeGetSubRows,
456
+ getExpandedRowModel: getExpandedRowModel(),
457
+ onExpandedChange,
458
+ }),
459
+ });
460
+
461
+ // Extract selected row IDs
462
+ const selectedIds = useMemo(() => {
463
+ if (!enableRowSelection) return new Set<string>();
464
+ return new Set(Object.keys(rowSelection));
465
+ }, [rowSelection, enableRowSelection]);
466
+
467
+ const clearSelection = useCallback(() => {
468
+ setRowSelection({});
469
+ }, []);
470
+
471
+ // Detect "no data" state - empty result set that's not due to loading.
472
+ // When nesting via `getParentId` we compare the root-level array length,
473
+ // so a fully-orphaned response still renders as empty.
474
+ const hasNoData = tableData.length === 0 && !isInitialLoading;
475
+
476
+ return {
477
+ table,
478
+ data: flattenedData,
479
+ isInitialLoading,
480
+ isRefetching,
481
+ isError,
482
+ hasNoData,
483
+ pagination: {
484
+ hasNextPage,
485
+ isFetchingNextPage,
486
+ fetchNextPage,
487
+ totalPages: data?.pages.length ?? 0,
488
+ },
489
+ selectedIds,
490
+ clearSelection,
491
+ setSorting: setSorting as OnChangeFn<SortingState>,
492
+ expanded: expandedState,
493
+ setExpanded: onExpandedChange,
494
+ };
495
+ }
@@ -0,0 +1,102 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+
3
+ export interface UseTableSelectionReturn {
4
+ isSelectionMode: boolean;
5
+ selectedIds: Set<string>;
6
+ selectedCount: number;
7
+ toggleSelectionMode: () => void;
8
+ toggleRow: (id: string) => void;
9
+ toggleSelectAll: () => void;
10
+ isRowSelected: (id: string) => boolean;
11
+ isAllSelected: boolean;
12
+ clearSelection: () => void;
13
+ getRowProps: (id: string) => {
14
+ onClick: (() => void) | undefined;
15
+ className: string | undefined;
16
+ };
17
+ }
18
+
19
+ export function useTableSelection<T extends Record<string, unknown>>(
20
+ data: T[],
21
+ idKey: keyof T = 'id',
22
+ ): UseTableSelectionReturn {
23
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
24
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
25
+
26
+ // Use a ref to store the current selectedIds to avoid recreating callbacks
27
+ const selectedIdsRef = useRef(selectedIds);
28
+ selectedIdsRef.current = selectedIds;
29
+
30
+ // Toggle selection mode on/off (clears selections when turning off)
31
+ const toggleSelectionMode = useCallback(() => {
32
+ setIsSelectionMode(prev => !prev);
33
+ setSelectedIds(new Set());
34
+ }, []);
35
+
36
+ // Toggle individual row selection
37
+ const toggleRow = useCallback((id: string) => {
38
+ setSelectedIds(prev => {
39
+ const next = new Set(prev);
40
+ if (next.has(id)) {
41
+ next.delete(id);
42
+ } else {
43
+ next.add(id);
44
+ }
45
+ return next;
46
+ });
47
+ }, []);
48
+
49
+ // Select/deselect all visible rows
50
+ const toggleSelectAll = useCallback(() => {
51
+ const allIds = data.map(item => String(item[idKey]));
52
+
53
+ setSelectedIds(prev => {
54
+ const allSelected = allIds.every(id => prev.has(id));
55
+ return allSelected ? new Set() : new Set(allIds);
56
+ });
57
+ }, [data, idKey]);
58
+
59
+ // Check if specific row is selected
60
+ // Uses ref to avoid recreating callback on every selection change
61
+ const isRowSelected = useCallback(
62
+ (id: string) => selectedIdsRef.current.has(id),
63
+ [],
64
+ );
65
+
66
+ // Check if all visible rows are selected
67
+ const isAllSelected = useMemo(() => {
68
+ if (data.length === 0) return false;
69
+ return data.every(item => selectedIds.has(String(item[idKey])));
70
+ }, [data, idKey, selectedIds]);
71
+
72
+ // Clear all selections
73
+ const clearSelection = useCallback(() => {
74
+ setSelectedIds(new Set());
75
+ }, []);
76
+
77
+ // Get props for table row (handles click + styling)
78
+ const getRowProps = useCallback(
79
+ (id: string) => ({
80
+ onClick: isSelectionMode ? () => toggleRow(id) : undefined,
81
+ className: isSelectionMode
82
+ ? isRowSelected(id)
83
+ ? 'selectable-row selected-row'
84
+ : 'selectable-row'
85
+ : undefined,
86
+ }),
87
+ [isSelectionMode, isRowSelected, toggleRow],
88
+ );
89
+
90
+ return {
91
+ isSelectionMode,
92
+ selectedIds,
93
+ selectedCount: selectedIds.size,
94
+ toggleSelectionMode,
95
+ toggleRow,
96
+ toggleSelectAll,
97
+ isRowSelected,
98
+ isAllSelected,
99
+ clearSelection,
100
+ getRowProps,
101
+ };
102
+ }