@emara/ui 1.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 (218) hide show
  1. package/components/ui/.gitkeep +0 -0
  2. package/components/ui/accordion.stories.tsx +231 -0
  3. package/components/ui/accordion.tsx +250 -0
  4. package/components/ui/app-shell.stories.tsx +270 -0
  5. package/components/ui/app-shell.tsx +491 -0
  6. package/components/ui/avatar.stories.tsx +174 -0
  7. package/components/ui/avatar.tsx +257 -0
  8. package/components/ui/badge.stories.tsx +127 -0
  9. package/components/ui/badge.tsx +146 -0
  10. package/components/ui/breadcrumb.stories.tsx +92 -0
  11. package/components/ui/breadcrumb.tsx +302 -0
  12. package/components/ui/button.stories.tsx +186 -0
  13. package/components/ui/button.tsx +128 -0
  14. package/components/ui/card.stories.tsx +279 -0
  15. package/components/ui/card.tsx +250 -0
  16. package/components/ui/checkbox.stories.tsx +93 -0
  17. package/components/ui/checkbox.tsx +131 -0
  18. package/components/ui/combobox.stories.tsx +489 -0
  19. package/components/ui/combobox.tsx +874 -0
  20. package/components/ui/context-menu.stories.tsx +202 -0
  21. package/components/ui/context-menu.tsx +309 -0
  22. package/components/ui/data-table.stories.tsx +227 -0
  23. package/components/ui/data-table.tsx +539 -0
  24. package/components/ui/date-picker.stories.tsx +225 -0
  25. package/components/ui/date-picker.tsx +597 -0
  26. package/components/ui/dialog.stories.tsx +193 -0
  27. package/components/ui/dialog.tsx +262 -0
  28. package/components/ui/divider.stories.tsx +84 -0
  29. package/components/ui/divider.tsx +135 -0
  30. package/components/ui/drawer.stories.tsx +218 -0
  31. package/components/ui/drawer.tsx +329 -0
  32. package/components/ui/dropdown-menu.stories.tsx +270 -0
  33. package/components/ui/dropdown-menu.tsx +353 -0
  34. package/components/ui/empty-state.stories.tsx +121 -0
  35. package/components/ui/empty-state.tsx +289 -0
  36. package/components/ui/field-group.stories.tsx +201 -0
  37. package/components/ui/field-group.tsx +276 -0
  38. package/components/ui/form.stories.tsx +219 -0
  39. package/components/ui/form.tsx +542 -0
  40. package/components/ui/input.stories.tsx +154 -0
  41. package/components/ui/input.tsx +208 -0
  42. package/components/ui/label.stories.tsx +84 -0
  43. package/components/ui/label.tsx +98 -0
  44. package/components/ui/page-header.stories.tsx +136 -0
  45. package/components/ui/page-header.tsx +315 -0
  46. package/components/ui/pagination.stories.tsx +136 -0
  47. package/components/ui/pagination.tsx +427 -0
  48. package/components/ui/popover.stories.tsx +212 -0
  49. package/components/ui/popover.tsx +167 -0
  50. package/components/ui/radio-group.stories.tsx +96 -0
  51. package/components/ui/radio-group.tsx +250 -0
  52. package/components/ui/select.stories.tsx +203 -0
  53. package/components/ui/select.tsx +318 -0
  54. package/components/ui/sidebar.stories.tsx +186 -0
  55. package/components/ui/sidebar.tsx +623 -0
  56. package/components/ui/skeleton.stories.tsx +131 -0
  57. package/components/ui/skeleton.tsx +311 -0
  58. package/components/ui/switch.stories.tsx +74 -0
  59. package/components/ui/switch.tsx +186 -0
  60. package/components/ui/table.stories.tsx +107 -0
  61. package/components/ui/table.tsx +285 -0
  62. package/components/ui/tabs.stories.tsx +222 -0
  63. package/components/ui/tabs.tsx +287 -0
  64. package/components/ui/textarea.stories.tsx +96 -0
  65. package/components/ui/textarea.tsx +182 -0
  66. package/components/ui/toast.stories.tsx +169 -0
  67. package/components/ui/toast.tsx +250 -0
  68. package/components/ui/tooltip.stories.tsx +146 -0
  69. package/components/ui/tooltip.tsx +156 -0
  70. package/components/ui/top-bar.stories.tsx +182 -0
  71. package/components/ui/top-bar.tsx +155 -0
  72. package/dist/components/ui/accordion.d.ts +45 -0
  73. package/dist/components/ui/accordion.d.ts.map +1 -0
  74. package/dist/components/ui/accordion.js +99 -0
  75. package/dist/components/ui/accordion.js.map +1 -0
  76. package/dist/components/ui/app-shell.d.ts +70 -0
  77. package/dist/components/ui/app-shell.d.ts.map +1 -0
  78. package/dist/components/ui/app-shell.js +199 -0
  79. package/dist/components/ui/app-shell.js.map +1 -0
  80. package/dist/components/ui/avatar.d.ts +41 -0
  81. package/dist/components/ui/avatar.d.ts.map +1 -0
  82. package/dist/components/ui/avatar.js +104 -0
  83. package/dist/components/ui/avatar.js.map +1 -0
  84. package/dist/components/ui/badge.d.ts +27 -0
  85. package/dist/components/ui/badge.d.ts.map +1 -0
  86. package/dist/components/ui/badge.js +65 -0
  87. package/dist/components/ui/badge.js.map +1 -0
  88. package/dist/components/ui/breadcrumb.d.ts +35 -0
  89. package/dist/components/ui/breadcrumb.d.ts.map +1 -0
  90. package/dist/components/ui/breadcrumb.js +88 -0
  91. package/dist/components/ui/breadcrumb.js.map +1 -0
  92. package/dist/components/ui/button.d.ts +26 -0
  93. package/dist/components/ui/button.d.ts.map +1 -0
  94. package/dist/components/ui/button.js +73 -0
  95. package/dist/components/ui/button.js.map +1 -0
  96. package/dist/components/ui/card.d.ts +52 -0
  97. package/dist/components/ui/card.d.ts.map +1 -0
  98. package/dist/components/ui/card.js +96 -0
  99. package/dist/components/ui/card.js.map +1 -0
  100. package/dist/components/ui/checkbox.d.ts +18 -0
  101. package/dist/components/ui/checkbox.d.ts.map +1 -0
  102. package/dist/components/ui/checkbox.js +59 -0
  103. package/dist/components/ui/checkbox.js.map +1 -0
  104. package/dist/components/ui/combobox.d.ts +194 -0
  105. package/dist/components/ui/combobox.d.ts.map +1 -0
  106. package/dist/components/ui/combobox.js +361 -0
  107. package/dist/components/ui/combobox.js.map +1 -0
  108. package/dist/components/ui/context-menu.d.ts +46 -0
  109. package/dist/components/ui/context-menu.d.ts.map +1 -0
  110. package/dist/components/ui/context-menu.js +95 -0
  111. package/dist/components/ui/context-menu.js.map +1 -0
  112. package/dist/components/ui/data-table.d.ts +53 -0
  113. package/dist/components/ui/data-table.d.ts.map +1 -0
  114. package/dist/components/ui/data-table.js +163 -0
  115. package/dist/components/ui/data-table.js.map +1 -0
  116. package/dist/components/ui/date-picker.d.ts +103 -0
  117. package/dist/components/ui/date-picker.d.ts.map +1 -0
  118. package/dist/components/ui/date-picker.js +306 -0
  119. package/dist/components/ui/date-picker.js.map +1 -0
  120. package/dist/components/ui/dialog.d.ts +40 -0
  121. package/dist/components/ui/dialog.d.ts.map +1 -0
  122. package/dist/components/ui/dialog.js +110 -0
  123. package/dist/components/ui/dialog.js.map +1 -0
  124. package/dist/components/ui/divider.d.ts +30 -0
  125. package/dist/components/ui/divider.d.ts.map +1 -0
  126. package/dist/components/ui/divider.js +62 -0
  127. package/dist/components/ui/divider.js.map +1 -0
  128. package/dist/components/ui/drawer.d.ts +56 -0
  129. package/dist/components/ui/drawer.d.ts.map +1 -0
  130. package/dist/components/ui/drawer.js +147 -0
  131. package/dist/components/ui/drawer.js.map +1 -0
  132. package/dist/components/ui/dropdown-menu.d.ts +63 -0
  133. package/dist/components/ui/dropdown-menu.d.ts.map +1 -0
  134. package/dist/components/ui/dropdown-menu.js +116 -0
  135. package/dist/components/ui/dropdown-menu.js.map +1 -0
  136. package/dist/components/ui/empty-state.d.ts +43 -0
  137. package/dist/components/ui/empty-state.d.ts.map +1 -0
  138. package/dist/components/ui/empty-state.js +128 -0
  139. package/dist/components/ui/empty-state.js.map +1 -0
  140. package/dist/components/ui/field-group.d.ts +38 -0
  141. package/dist/components/ui/field-group.d.ts.map +1 -0
  142. package/dist/components/ui/field-group.js +107 -0
  143. package/dist/components/ui/field-group.js.map +1 -0
  144. package/dist/components/ui/form.d.ts +67 -0
  145. package/dist/components/ui/form.d.ts.map +1 -0
  146. package/dist/components/ui/form.js +286 -0
  147. package/dist/components/ui/form.js.map +1 -0
  148. package/dist/components/ui/input.d.ts +36 -0
  149. package/dist/components/ui/input.d.ts.map +1 -0
  150. package/dist/components/ui/input.js +99 -0
  151. package/dist/components/ui/input.js.map +1 -0
  152. package/dist/components/ui/label.d.ts +37 -0
  153. package/dist/components/ui/label.d.ts.map +1 -0
  154. package/dist/components/ui/label.js +34 -0
  155. package/dist/components/ui/label.js.map +1 -0
  156. package/dist/components/ui/page-header.d.ts +65 -0
  157. package/dist/components/ui/page-header.d.ts.map +1 -0
  158. package/dist/components/ui/page-header.js +140 -0
  159. package/dist/components/ui/page-header.js.map +1 -0
  160. package/dist/components/ui/pagination.d.ts +67 -0
  161. package/dist/components/ui/pagination.d.ts.map +1 -0
  162. package/dist/components/ui/pagination.js +109 -0
  163. package/dist/components/ui/pagination.js.map +1 -0
  164. package/dist/components/ui/popover.d.ts +28 -0
  165. package/dist/components/ui/popover.d.ts.map +1 -0
  166. package/dist/components/ui/popover.js +85 -0
  167. package/dist/components/ui/popover.js.map +1 -0
  168. package/dist/components/ui/radio-group.d.ts +35 -0
  169. package/dist/components/ui/radio-group.d.ts.map +1 -0
  170. package/dist/components/ui/radio-group.js +103 -0
  171. package/dist/components/ui/radio-group.js.map +1 -0
  172. package/dist/components/ui/select.d.ts +42 -0
  173. package/dist/components/ui/select.d.ts.map +1 -0
  174. package/dist/components/ui/select.js +86 -0
  175. package/dist/components/ui/select.js.map +1 -0
  176. package/dist/components/ui/sidebar.d.ts +59 -0
  177. package/dist/components/ui/sidebar.d.ts.map +1 -0
  178. package/dist/components/ui/sidebar.js +189 -0
  179. package/dist/components/ui/sidebar.js.map +1 -0
  180. package/dist/components/ui/skeleton.d.ts +77 -0
  181. package/dist/components/ui/skeleton.d.ts.map +1 -0
  182. package/dist/components/ui/skeleton.js +115 -0
  183. package/dist/components/ui/skeleton.js.map +1 -0
  184. package/dist/components/ui/switch.d.ts +26 -0
  185. package/dist/components/ui/switch.d.ts.map +1 -0
  186. package/dist/components/ui/switch.js +84 -0
  187. package/dist/components/ui/switch.js.map +1 -0
  188. package/dist/components/ui/table.d.ts +52 -0
  189. package/dist/components/ui/table.d.ts.map +1 -0
  190. package/dist/components/ui/table.js +109 -0
  191. package/dist/components/ui/table.js.map +1 -0
  192. package/dist/components/ui/tabs.d.ts +42 -0
  193. package/dist/components/ui/tabs.d.ts.map +1 -0
  194. package/dist/components/ui/tabs.js +163 -0
  195. package/dist/components/ui/tabs.js.map +1 -0
  196. package/dist/components/ui/textarea.d.ts +26 -0
  197. package/dist/components/ui/textarea.d.ts.map +1 -0
  198. package/dist/components/ui/textarea.js +96 -0
  199. package/dist/components/ui/textarea.js.map +1 -0
  200. package/dist/components/ui/toast.d.ts +77 -0
  201. package/dist/components/ui/toast.d.ts.map +1 -0
  202. package/dist/components/ui/toast.js +141 -0
  203. package/dist/components/ui/toast.js.map +1 -0
  204. package/dist/components/ui/tooltip.d.ts +31 -0
  205. package/dist/components/ui/tooltip.d.ts.map +1 -0
  206. package/dist/components/ui/tooltip.js +71 -0
  207. package/dist/components/ui/tooltip.js.map +1 -0
  208. package/dist/components/ui/top-bar.d.ts +30 -0
  209. package/dist/components/ui/top-bar.d.ts.map +1 -0
  210. package/dist/components/ui/top-bar.js +64 -0
  211. package/dist/components/ui/top-bar.js.map +1 -0
  212. package/dist/lib/utils.d.ts +3 -0
  213. package/dist/lib/utils.d.ts.map +1 -0
  214. package/dist/lib/utils.js +6 -0
  215. package/dist/lib/utils.js.map +1 -0
  216. package/lib/utils.ts +6 -0
  217. package/package.json +112 -0
  218. package/styles/globals.css +685 -0
@@ -0,0 +1,539 @@
1
+ "use client";
2
+
3
+ import { forwardRef, Fragment, useId, useMemo, useState, type ReactNode } from "react";
4
+ import {
5
+ flexRender,
6
+ getCoreRowModel,
7
+ getExpandedRowModel,
8
+ getFilteredRowModel,
9
+ getPaginationRowModel,
10
+ getSortedRowModel,
11
+ useReactTable,
12
+ type ColumnDef,
13
+ type ExpandedState,
14
+ type RowSelectionState,
15
+ type SortingState,
16
+ type VisibilityState,
17
+ } from "@tanstack/react-table";
18
+ import {
19
+ RiArrowDownLine,
20
+ RiArrowUpDownLine,
21
+ RiArrowUpLine,
22
+ RiSearchLine,
23
+ RiSettings3Line,
24
+ } from "@remixicon/react";
25
+
26
+ import { cn } from "@/lib/utils";
27
+ import { Button } from "./button";
28
+ import { Checkbox } from "./checkbox";
29
+ import { DataPagination } from "./pagination";
30
+ import {
31
+ DropdownMenu,
32
+ DropdownMenuCheckboxItem,
33
+ DropdownMenuContent,
34
+ DropdownMenuLabel,
35
+ DropdownMenuSeparator,
36
+ DropdownMenuTrigger,
37
+ } from "./dropdown-menu";
38
+ import { EmptyState } from "./empty-state";
39
+ import { Input } from "./input";
40
+ import { Skeleton } from "./skeleton";
41
+ import {
42
+ Table,
43
+ TableBody,
44
+ TableCell,
45
+ TableHead,
46
+ TableHeader,
47
+ TableRow,
48
+ type TableDensity,
49
+ } from "./table";
50
+
51
+ // Per docs/emara-ui-phase-4-components.md §1.2. v1 features:
52
+ // - data + columns
53
+ // - sorting / multi-sort
54
+ // - selection (none/single/multi)
55
+ // - global search
56
+ // - column visibility toggle
57
+ // - row expand
58
+ // - loading / empty / error states
59
+ // - pagination integration via DataPagination
60
+ //
61
+ // Deferred to v1.1 of DataTable: column filtering UI in headers, column
62
+ // resize, column pinning, virtualization. The TanStack feature flags wire up
63
+ // the same way when those land.
64
+
65
+ // --- Emara column meta extensions ------------------------------------------
66
+
67
+ type EmaraAlign = "start" | "center" | "end";
68
+
69
+ declare module "@tanstack/react-table" {
70
+ // The TData/TValue type params are required by TanStack's module
71
+ // declaration; we don't use them directly here.
72
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
73
+ interface ColumnMeta<TData, TValue> {
74
+ /** Logical alignment for header + cells. RTL-aware. */
75
+ align?: EmaraAlign;
76
+ /** Fixed/min width. */
77
+ width?: number | string;
78
+ /** Single-line with ellipsis. */
79
+ truncate?: boolean;
80
+ /** Pin column to start/end. (Visual style only in v1; pin layout deferred.) */
81
+ sticky?: "start" | "end";
82
+ }
83
+ }
84
+
85
+ // --- Props -----------------------------------------------------------------
86
+
87
+ type SelectionMode = "none" | "single" | "multi";
88
+
89
+ interface DataTableProps<T> {
90
+ data: T[];
91
+ columns: ColumnDef<T, unknown>[];
92
+
93
+ loading?: boolean;
94
+ error?: ReactNode;
95
+ empty?: ReactNode;
96
+
97
+ density?: TableDensity;
98
+ striped?: boolean;
99
+ bordered?: boolean;
100
+ hoverable?: boolean;
101
+ stickyHeader?: boolean;
102
+
103
+ enableSorting?: boolean;
104
+ enableMultiSort?: boolean;
105
+
106
+ enableGlobalSearch?: boolean;
107
+ /** Placeholder for the global search input. */
108
+ searchPlaceholder?: string;
109
+
110
+ enableSelection?: SelectionMode;
111
+ onSelectionChange?: (rows: T[]) => void;
112
+ getRowId?: (row: T, index: number) => string;
113
+
114
+ enablePagination?: boolean;
115
+ pageSize?: number;
116
+ pageSizeOptions?: number[];
117
+
118
+ enableColumnVisibility?: boolean;
119
+
120
+ enableRowExpand?: boolean;
121
+ renderExpandedRow?: (row: T) => ReactNode;
122
+
123
+ toolbar?: ReactNode;
124
+ toolbarActions?: ReactNode;
125
+ rowClassName?: (row: T) => string;
126
+
127
+ className?: string;
128
+ }
129
+
130
+ // --- Helpers ---------------------------------------------------------------
131
+
132
+ function alignClass(align: EmaraAlign | undefined): string {
133
+ switch (align) {
134
+ case "center":
135
+ return "text-center";
136
+ case "end":
137
+ return "text-end";
138
+ case "start":
139
+ case undefined:
140
+ default:
141
+ return "text-start";
142
+ }
143
+ }
144
+
145
+ function widthStyle(w: number | string | undefined): React.CSSProperties | undefined {
146
+ if (w === undefined) return undefined;
147
+ return { width: typeof w === "number" ? `${w}px` : w };
148
+ }
149
+
150
+ // --- DataTable -------------------------------------------------------------
151
+
152
+ function DataTableInner<T>(props: DataTableProps<T>, ref: React.Ref<HTMLDivElement>) {
153
+ const {
154
+ data,
155
+ columns,
156
+ loading = false,
157
+ error,
158
+ empty,
159
+ density,
160
+ striped,
161
+ bordered,
162
+ hoverable,
163
+ stickyHeader,
164
+ enableSorting = false,
165
+ enableMultiSort = false,
166
+ enableGlobalSearch = false,
167
+ searchPlaceholder = "Search…",
168
+ enableSelection = "none",
169
+ onSelectionChange,
170
+ getRowId,
171
+ enablePagination = false,
172
+ pageSize = 25,
173
+ pageSizeOptions = [10, 25, 50, 100],
174
+ enableColumnVisibility = false,
175
+ enableRowExpand = false,
176
+ renderExpandedRow,
177
+ toolbar,
178
+ toolbarActions,
179
+ rowClassName,
180
+ className,
181
+ } = props;
182
+
183
+ const searchInputId = useId();
184
+
185
+ const [sorting, setSorting] = useState<SortingState>([]);
186
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
187
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
188
+ const [globalFilter, setGlobalFilter] = useState("");
189
+ const [expanded, setExpanded] = useState<ExpandedState>({});
190
+ const [pagination, setPagination] = useState({ pageIndex: 0, pageSize });
191
+
192
+ // Inject selection column if needed.
193
+ const augmentedColumns = useMemo<ColumnDef<T, unknown>[]>(() => {
194
+ const cols: ColumnDef<T, unknown>[] = [...columns];
195
+ if (enableSelection !== "none") {
196
+ cols.unshift({
197
+ id: "__select",
198
+ size: 36,
199
+ header: ({ table }) =>
200
+ enableSelection === "multi" ? (
201
+ <Checkbox
202
+ aria-label="Select all rows"
203
+ checked={
204
+ table.getIsAllPageRowsSelected()
205
+ ? true
206
+ : table.getIsSomePageRowsSelected()
207
+ ? "indeterminate"
208
+ : false
209
+ }
210
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(Boolean(v))}
211
+ />
212
+ ) : null,
213
+ cell: ({ row }) => (
214
+ <Checkbox
215
+ aria-label={`Select row ${row.index + 1}`}
216
+ checked={row.getIsSelected()}
217
+ onCheckedChange={(v) => {
218
+ if (enableSelection === "single") {
219
+ row.toggleSelected(Boolean(v));
220
+ } else {
221
+ row.toggleSelected(Boolean(v));
222
+ }
223
+ }}
224
+ />
225
+ ),
226
+ enableSorting: false,
227
+ enableHiding: false,
228
+ meta: { align: "center" },
229
+ });
230
+ }
231
+ return cols;
232
+ }, [columns, enableSelection]);
233
+
234
+ const table = useReactTable({
235
+ data,
236
+ columns: augmentedColumns,
237
+ state: {
238
+ sorting,
239
+ rowSelection,
240
+ columnVisibility,
241
+ globalFilter,
242
+ expanded,
243
+ ...(enablePagination ? { pagination } : {}),
244
+ },
245
+ enableSorting,
246
+ enableMultiSort,
247
+ enableRowSelection: enableSelection !== "none",
248
+ enableMultiRowSelection: enableSelection === "multi",
249
+ onSortingChange: setSorting,
250
+ onRowSelectionChange: (next) => {
251
+ setRowSelection((prev) => {
252
+ const nextState = typeof next === "function" ? next(prev) : next;
253
+ // Single-mode: keep at most one selected row.
254
+ if (enableSelection === "single") {
255
+ const keys = Object.keys(nextState).filter((k) => nextState[k]);
256
+ const lastKey = keys[keys.length - 1];
257
+ const cleaned: RowSelectionState = lastKey ? { [lastKey]: true } : {};
258
+ queueMicrotask(() => {
259
+ const rows = table
260
+ .getRowModel()
261
+ .rows.filter((r) => cleaned[r.id])
262
+ .map((r) => r.original);
263
+ onSelectionChange?.(rows);
264
+ });
265
+ return cleaned;
266
+ }
267
+ queueMicrotask(() => {
268
+ const rows = table
269
+ .getRowModel()
270
+ .rows.filter((r) => nextState[r.id])
271
+ .map((r) => r.original);
272
+ onSelectionChange?.(rows);
273
+ });
274
+ return nextState;
275
+ });
276
+ },
277
+ onColumnVisibilityChange: setColumnVisibility,
278
+ onGlobalFilterChange: setGlobalFilter,
279
+ onExpandedChange: setExpanded,
280
+ ...(enablePagination ? { onPaginationChange: setPagination } : {}),
281
+ getCoreRowModel: getCoreRowModel(),
282
+ ...(enableSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
283
+ ...(enableGlobalSearch ? { getFilteredRowModel: getFilteredRowModel() } : {}),
284
+ ...(enablePagination ? { getPaginationRowModel: getPaginationRowModel() } : {}),
285
+ ...(enableRowExpand ? { getExpandedRowModel: getExpandedRowModel() } : {}),
286
+ ...(getRowId ? { getRowId } : {}),
287
+ });
288
+
289
+ const visibleColumnCount = table.getVisibleLeafColumns().length;
290
+ const showSkeletonRows = loading;
291
+ const showError = !loading && error !== undefined;
292
+ const showEmpty = !loading && !showError && table.getRowModel().rows.length === 0;
293
+ const skeletonRows = pageSize > 0 ? Math.min(pageSize, 8) : 5;
294
+
295
+ // Toolbar
296
+ const showColumnToggle = enableColumnVisibility;
297
+ const showSearch = enableGlobalSearch;
298
+ const showToolbar = toolbar !== undefined || showSearch || showColumnToggle || toolbarActions;
299
+
300
+ // Polite live-region announcement for sort changes. `aria-sort` on each
301
+ // <th> exposes per-column state at focus time, but state CHANGES are
302
+ // silent without a live region. Per spec phase-4 §118 (sort announcements).
303
+ const sortAnnouncement =
304
+ sorting.length > 0
305
+ ? `Sorted by ${sorting[0]!.id}, ${sorting[0]!.desc ? "descending" : "ascending"}`
306
+ : "";
307
+
308
+ return (
309
+ <div ref={ref} className={cn("space-y-3", className)}>
310
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
311
+ {sortAnnouncement}
312
+ </div>
313
+ {showToolbar ? (
314
+ <div className="flex flex-wrap items-center gap-2">
315
+ {toolbar ?? (
316
+ <>
317
+ {showSearch ? (
318
+ <div className="w-72">
319
+ <Input
320
+ id={searchInputId}
321
+ type="search"
322
+ placeholder={searchPlaceholder}
323
+ value={globalFilter}
324
+ onChange={(e) => setGlobalFilter(e.target.value)}
325
+ startAdornment={<RiSearchLine />}
326
+ clearable
327
+ onClear={() => setGlobalFilter("")}
328
+ aria-label="Search rows"
329
+ />
330
+ </div>
331
+ ) : null}
332
+ <div className="ms-auto flex items-center gap-2">
333
+ {toolbarActions}
334
+ {showColumnToggle ? (
335
+ <DropdownMenu>
336
+ <DropdownMenuTrigger asChild>
337
+ <Button variant="outline" size="sm" leftIcon={<RiSettings3Line />}>
338
+ Columns
339
+ </Button>
340
+ </DropdownMenuTrigger>
341
+ <DropdownMenuContent align="end">
342
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
343
+ <DropdownMenuSeparator />
344
+ {table
345
+ .getAllLeafColumns()
346
+ .filter((c) => c.id !== "__select" && c.getCanHide())
347
+ .map((col) => (
348
+ <DropdownMenuCheckboxItem
349
+ key={col.id}
350
+ checked={col.getIsVisible()}
351
+ onCheckedChange={(v) => col.toggleVisibility(Boolean(v))}
352
+ >
353
+ {String(col.columnDef.header ?? col.id)}
354
+ </DropdownMenuCheckboxItem>
355
+ ))}
356
+ </DropdownMenuContent>
357
+ </DropdownMenu>
358
+ ) : null}
359
+ </div>
360
+ </>
361
+ )}
362
+ </div>
363
+ ) : null}
364
+
365
+ <Table
366
+ {...(density !== undefined ? { density } : {})}
367
+ {...(striped !== undefined ? { striped } : {})}
368
+ {...(bordered !== undefined ? { bordered } : {})}
369
+ {...(hoverable !== undefined ? { hoverable } : {})}
370
+ {...(stickyHeader !== undefined ? { stickyHeader } : {})}
371
+ >
372
+ <TableHeader>
373
+ {table.getHeaderGroups().map((headerGroup) => (
374
+ <TableRow key={headerGroup.id}>
375
+ {headerGroup.headers.map((header) => {
376
+ const meta = header.column.columnDef.meta;
377
+ const align = meta?.align;
378
+ const sortDir = header.column.getIsSorted();
379
+ const canSort = header.column.getCanSort();
380
+ return (
381
+ <TableHead
382
+ key={header.id}
383
+ style={widthStyle(meta?.width)}
384
+ aria-sort={
385
+ canSort
386
+ ? sortDir === "asc"
387
+ ? "ascending"
388
+ : sortDir === "desc"
389
+ ? "descending"
390
+ : "none"
391
+ : undefined
392
+ }
393
+ className={cn(alignClass(align), meta?.truncate && "truncate")}
394
+ >
395
+ {header.isPlaceholder ? null : canSort ? (
396
+ <button
397
+ type="button"
398
+ onClick={(e) =>
399
+ header.column.toggleSorting(undefined, e.shiftKey && enableMultiSort)
400
+ }
401
+ className={cn(
402
+ "inline-flex cursor-pointer items-center gap-1 rounded-sm select-none",
403
+ "hover:text-foreground",
404
+ "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
405
+ "[&_svg]:size-3.5 [&_svg]:shrink-0",
406
+ )}
407
+ >
408
+ <span>
409
+ {flexRender(header.column.columnDef.header, header.getContext())}
410
+ </span>
411
+ {sortDir === "asc" ? (
412
+ <RiArrowUpLine className="rtl-mirror" />
413
+ ) : sortDir === "desc" ? (
414
+ <RiArrowDownLine className="rtl-mirror" />
415
+ ) : (
416
+ <RiArrowUpDownLine className="rtl-mirror opacity-50" />
417
+ )}
418
+ </button>
419
+ ) : (
420
+ flexRender(header.column.columnDef.header, header.getContext())
421
+ )}
422
+ </TableHead>
423
+ );
424
+ })}
425
+ </TableRow>
426
+ ))}
427
+ </TableHeader>
428
+
429
+ <TableBody>
430
+ {showSkeletonRows ? (
431
+ Array.from({ length: skeletonRows }, (_, i) => (
432
+ <TableRow key={`skeleton-${i}`}>
433
+ {Array.from({ length: visibleColumnCount }, (__, c) => (
434
+ <TableCell key={c}>
435
+ <Skeleton className="h-4 w-full" />
436
+ </TableCell>
437
+ ))}
438
+ </TableRow>
439
+ ))
440
+ ) : showError ? (
441
+ <TableRow>
442
+ <TableCell colSpan={visibleColumnCount} className="p-0">
443
+ <div className="p-6">{error}</div>
444
+ </TableCell>
445
+ </TableRow>
446
+ ) : showEmpty ? (
447
+ <TableRow>
448
+ <TableCell colSpan={visibleColumnCount} className="p-0">
449
+ {empty ?? (
450
+ <EmptyState
451
+ size="sm"
452
+ title="No results"
453
+ description="There are no rows to display."
454
+ />
455
+ )}
456
+ </TableCell>
457
+ </TableRow>
458
+ ) : (
459
+ table.getRowModel().rows.map((row) => (
460
+ <Fragment key={row.id}>
461
+ <TableRow
462
+ data-state={row.getIsSelected() ? "selected" : undefined}
463
+ aria-selected={row.getIsSelected() || undefined}
464
+ className={rowClassName?.(row.original)}
465
+ >
466
+ {row.getVisibleCells().map((cell, cellIdx) => {
467
+ const meta = cell.column.columnDef.meta;
468
+ const expandCellIdx = enableSelection !== "none" ? 1 : 0;
469
+ return (
470
+ <TableCell
471
+ key={cell.id}
472
+ style={widthStyle(meta?.width)}
473
+ className={cn(alignClass(meta?.align), meta?.truncate && "truncate")}
474
+ >
475
+ {enableRowExpand && cellIdx === expandCellIdx ? (
476
+ <div className="flex items-center gap-2">
477
+ <button
478
+ type="button"
479
+ onClick={() => row.toggleExpanded()}
480
+ aria-label={row.getIsExpanded() ? "Collapse row" : "Expand row"}
481
+ aria-expanded={row.getIsExpanded()}
482
+ className={cn(
483
+ "inline-flex size-6 items-center justify-center rounded",
484
+ "hover:bg-accent cursor-pointer",
485
+ "focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
486
+ "[&_svg]:size-4 [&_svg]:shrink-0",
487
+ )}
488
+ >
489
+ {row.getIsExpanded() ? (
490
+ <RiArrowDownLine />
491
+ ) : (
492
+ <RiArrowUpDownLine className="rtl-mirror" />
493
+ )}
494
+ </button>
495
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
496
+ </div>
497
+ ) : (
498
+ flexRender(cell.column.columnDef.cell, cell.getContext())
499
+ )}
500
+ </TableCell>
501
+ );
502
+ })}
503
+ </TableRow>
504
+ {enableRowExpand && row.getIsExpanded() ? (
505
+ <TableRow>
506
+ <TableCell colSpan={row.getVisibleCells().length} className="bg-muted/30">
507
+ {renderExpandedRow?.(row.original) ?? null}
508
+ </TableCell>
509
+ </TableRow>
510
+ ) : null}
511
+ </Fragment>
512
+ ))
513
+ )}
514
+ </TableBody>
515
+ </Table>
516
+
517
+ {enablePagination && !loading && !showError ? (
518
+ <DataPagination
519
+ page={table.getState().pagination.pageIndex + 1}
520
+ pageCount={Math.max(1, table.getPageCount())}
521
+ onPageChange={(p) => table.setPageIndex(p - 1)}
522
+ pageSize={table.getState().pagination.pageSize}
523
+ pageSizeOptions={pageSizeOptions}
524
+ onPageSizeChange={(s) => table.setPageSize(s)}
525
+ totalItems={table.getFilteredRowModel().rows.length}
526
+ />
527
+ ) : null}
528
+ </div>
529
+ );
530
+ }
531
+
532
+ const DataTable = forwardRef(DataTableInner) as <T>(
533
+ props: DataTableProps<T> & { ref?: React.Ref<HTMLDivElement> },
534
+ ) => React.ReactElement;
535
+
536
+ (DataTable as unknown as { displayName: string }).displayName = "DataTable";
537
+
538
+ export { DataTable };
539
+ export type { DataTableProps, SelectionMode };