banhaten 0.3.0 → 0.5.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 (348) hide show
  1. package/CHANGELOG.md +139 -73
  2. package/CONTRIBUTING.md +46 -44
  3. package/README.md +192 -190
  4. package/SECURITY.md +15 -15
  5. package/banhaten.config.example.json +11 -11
  6. package/docs/design-spacing.md +13 -0
  7. package/docs/design-system/README.md +22 -20
  8. package/docs/design-system/adoption-quality-remediation.md +87 -87
  9. package/docs/design-system/agent-usage.md +13 -13
  10. package/docs/design-system/appearance-presets.md +189 -189
  11. package/docs/design-system/appearances/default.md +94 -94
  12. package/docs/design-system/appearances/rounded.md +95 -95
  13. package/docs/design-system/appearances/sharp.md +95 -95
  14. package/docs/design-system/component-docs-pattern.md +81 -81
  15. package/docs/design-system/component-implementation-ownership-audit.md +245 -244
  16. package/docs/design-system/component-quality-audit-2026-08-20.md +225 -223
  17. package/docs/design-system/component-showcase-consistency-report.md +219 -219
  18. package/docs/design-system/component-token-consistency-audit.md +173 -173
  19. package/docs/design-system/components/README.md +90 -76
  20. package/docs/design-system/components/accordion.md +53 -53
  21. package/docs/design-system/components/activity-feed.md +94 -94
  22. package/docs/design-system/components/activity-heatmap.md +57 -57
  23. package/docs/design-system/components/agent-input.md +85 -0
  24. package/docs/design-system/components/alert-dialog.md +72 -72
  25. package/docs/design-system/components/alert.md +98 -98
  26. package/docs/design-system/components/approval-card.md +134 -0
  27. package/docs/design-system/components/aspect-ratio.md +45 -45
  28. package/docs/design-system/components/attribute.md +89 -89
  29. package/docs/design-system/components/autocomplete.md +75 -75
  30. package/docs/design-system/components/avatar.md +54 -54
  31. package/docs/design-system/components/badge.md +54 -54
  32. package/docs/design-system/components/banner.md +87 -87
  33. package/docs/design-system/components/breadcrumbs.md +177 -176
  34. package/docs/design-system/components/button-group.md +98 -98
  35. package/docs/design-system/components/button.md +83 -80
  36. package/docs/design-system/components/card.md +88 -88
  37. package/docs/design-system/components/carousel.md +46 -46
  38. package/docs/design-system/components/catalog-components.md +47 -47
  39. package/docs/design-system/components/chart.md +95 -24
  40. package/docs/design-system/components/checkbox.md +54 -54
  41. package/docs/design-system/components/collapsible.md +67 -53
  42. package/docs/design-system/components/command.md +62 -62
  43. package/docs/design-system/components/context-menu.md +46 -46
  44. package/docs/design-system/components/date-picker.md +81 -79
  45. package/docs/design-system/components/divider.md +102 -102
  46. package/docs/design-system/components/empty-state.md +56 -56
  47. package/docs/design-system/components/field.md +70 -70
  48. package/docs/design-system/components/file-upload.md +187 -187
  49. package/docs/design-system/components/hover-card.md +48 -48
  50. package/docs/design-system/components/icons.md +48 -48
  51. package/docs/design-system/components/inline-citations.md +93 -0
  52. package/docs/design-system/components/input-group.md +58 -58
  53. package/docs/design-system/components/input-otp.md +56 -56
  54. package/docs/design-system/components/input.md +117 -61
  55. package/docs/design-system/components/item.md +59 -57
  56. package/docs/design-system/components/kbd.md +54 -49
  57. package/docs/design-system/components/label.md +49 -49
  58. package/docs/design-system/components/menu.md +71 -61
  59. package/docs/design-system/components/menubar.md +47 -47
  60. package/docs/design-system/components/modal.md +105 -105
  61. package/docs/design-system/components/native-select.md +54 -54
  62. package/docs/design-system/components/navigation-menu.md +50 -50
  63. package/docs/design-system/components/onboarding-step-list-item.md +82 -82
  64. package/docs/design-system/components/page-header.md +93 -86
  65. package/docs/design-system/components/pagination.md +57 -57
  66. package/docs/design-system/components/popover.md +60 -60
  67. package/docs/design-system/components/progress-slider.md +50 -50
  68. package/docs/design-system/components/progress.md +76 -76
  69. package/docs/design-system/components/radio-card.md +51 -51
  70. package/docs/design-system/components/radio-group.md +57 -57
  71. package/docs/design-system/components/resizable.md +44 -44
  72. package/docs/design-system/components/rolling-count.md +61 -0
  73. package/docs/design-system/components/scroll-area.md +51 -51
  74. package/docs/design-system/components/segmented-control.md +54 -54
  75. package/docs/design-system/components/select.md +68 -62
  76. package/docs/design-system/components/sheet.md +67 -67
  77. package/docs/design-system/components/shimmer-text.md +67 -0
  78. package/docs/design-system/components/sidebar.md +127 -76
  79. package/docs/design-system/components/skeleton.md +75 -75
  80. package/docs/design-system/components/slideout.md +65 -65
  81. package/docs/design-system/components/slider.md +63 -63
  82. package/docs/design-system/components/social-button.md +50 -50
  83. package/docs/design-system/components/spinner.md +62 -62
  84. package/docs/design-system/components/steps.md +65 -65
  85. package/docs/design-system/components/streaming-text.md +82 -0
  86. package/docs/design-system/components/table.md +419 -414
  87. package/docs/design-system/components/tabs.md +59 -55
  88. package/docs/design-system/components/tag.md +80 -80
  89. package/docs/design-system/components/task-list.md +95 -0
  90. package/docs/design-system/components/textarea.md +50 -50
  91. package/docs/design-system/components/thinking-reasoning.md +72 -0
  92. package/docs/design-system/components/thinking-state.md +67 -0
  93. package/docs/design-system/components/timeline.md +83 -83
  94. package/docs/design-system/components/toast.md +58 -58
  95. package/docs/design-system/components/toggle.md +81 -81
  96. package/docs/design-system/components/toolbar.md +92 -87
  97. package/docs/design-system/components/tooltip.md +94 -92
  98. package/docs/design-system/components/typography.md +18 -18
  99. package/docs/design-system/density-and-elevation.md +65 -61
  100. package/docs/design-system/design-system-test-missing-items.md +368 -368
  101. package/docs/design-system/direction-and-localization.md +23 -23
  102. package/docs/design-system/form-recipes.md +73 -73
  103. package/docs/design-system/gap-promotion-policy.md +21 -21
  104. package/docs/design-system/icons.md +74 -69
  105. package/docs/design-system/installation.md +70 -68
  106. package/docs/design-system/launch-comparison-shadcn.md +98 -98
  107. package/docs/design-system/registry-and-cli.md +64 -64
  108. package/docs/design-system/release-policy.md +30 -30
  109. package/docs/design-system/roadmap.md +14 -14
  110. package/docs/design-system/support-matrix.md +23 -23
  111. package/docs/design-system/tabs.md +54 -53
  112. package/docs/design-system/template-gap-audit.md +32 -0
  113. package/docs/design-system/templates.md +59 -0
  114. package/docs/design-system/token-governance.md +72 -60
  115. package/package.json +98 -98
  116. package/registry/component-contract.json +40 -40
  117. package/registry/components/accordion.tsx +155 -155
  118. package/registry/components/agent-input-parts.tsx +552 -0
  119. package/registry/components/agent-input-state.ts +156 -0
  120. package/registry/components/agent-input.css +307 -0
  121. package/registry/components/agent-input.tsx +382 -0
  122. package/registry/components/alert-dialog.tsx +297 -297
  123. package/registry/components/alert.tsx +293 -293
  124. package/registry/components/approval-card.css +409 -0
  125. package/registry/components/approval-card.tsx +401 -0
  126. package/registry/components/approval-frame.tsx +108 -0
  127. package/registry/components/approval-questions.tsx +559 -0
  128. package/registry/components/aspect-ratio.tsx +30 -30
  129. package/registry/components/attribute.tsx +454 -448
  130. package/registry/components/autocomplete.tsx +598 -651
  131. package/registry/components/avatar.tsx +385 -381
  132. package/registry/components/badge.tsx +629 -629
  133. package/registry/components/button-group.tsx +565 -565
  134. package/registry/components/button.tsx +127 -123
  135. package/registry/components/card.tsx +638 -638
  136. package/registry/components/carousel.tsx +296 -296
  137. package/registry/components/chart-block-data.ts +177 -0
  138. package/registry/components/chart-block.css +171 -0
  139. package/registry/components/chart-block.tsx +260 -0
  140. package/registry/components/chart-interactions.ts +123 -0
  141. package/registry/components/chart-options.cartesian.ts +162 -0
  142. package/registry/components/chart-options.paint.ts +99 -0
  143. package/registry/components/chart-options.parts.ts +72 -0
  144. package/registry/components/chart-options.radial.ts +109 -0
  145. package/registry/components/chart-options.ts +44 -0
  146. package/registry/components/chart-options.types.ts +67 -0
  147. package/registry/components/chart.tsx +203 -203
  148. package/registry/components/checkbox.tsx +247 -247
  149. package/registry/components/collapsible.tsx +109 -69
  150. package/registry/components/command.tsx +208 -208
  151. package/registry/components/context-menu.tsx +236 -236
  152. package/registry/components/data-chart.tsx +40 -0
  153. package/registry/components/date-picker.tsx +1289 -1277
  154. package/registry/components/echart-theme.ts +246 -0
  155. package/registry/components/echart.css +49 -0
  156. package/registry/components/echart.tsx +178 -0
  157. package/registry/components/expanded/ActivityFeed.tsx +227 -227
  158. package/registry/components/expanded/ActivityHeatmap.tsx +64 -64
  159. package/registry/components/expanded/Banner.tsx +179 -179
  160. package/registry/components/expanded/Breadcrumbs.tsx +131 -131
  161. package/registry/components/expanded/CatalogComponentsShowcase.tsx +205 -205
  162. package/registry/components/expanded/CatalogDivider.tsx +49 -49
  163. package/registry/components/expanded/EmptyState.tsx +155 -155
  164. package/registry/components/expanded/FileUpload.tsx +543 -543
  165. package/registry/components/expanded/OnboardingStepListItem.tsx +60 -59
  166. package/registry/components/expanded/PageHeader.tsx +179 -166
  167. package/registry/components/expanded/Slideout.tsx +108 -108
  168. package/registry/components/expanded/Steps.tsx +402 -402
  169. package/registry/components/expanded/Table.tsx +1092 -1071
  170. package/registry/components/expanded/Timeline.tsx +203 -203
  171. package/registry/components/expanded/activityFeed.css +248 -248
  172. package/registry/components/expanded/activityHeatmap.css +66 -66
  173. package/registry/components/expanded/banner.css +260 -255
  174. package/registry/components/expanded/breadcrumbs.css +140 -140
  175. package/registry/components/expanded/catalogComponentsShowcase.css +87 -87
  176. package/registry/components/expanded/divider.css +75 -75
  177. package/registry/components/expanded/emptyState.css +111 -111
  178. package/registry/components/expanded/fileUpload.css +457 -457
  179. package/registry/components/expanded/onboardingStepListItem.css +135 -126
  180. package/registry/components/expanded/pageHeader.css +248 -290
  181. package/registry/components/expanded/slideout.css +276 -276
  182. package/registry/components/expanded/steps.css +356 -356
  183. package/registry/components/expanded/table.css +606 -603
  184. package/registry/components/expanded/timeline.css +286 -286
  185. package/registry/components/field.tsx +283 -283
  186. package/registry/components/hover-card.tsx +48 -48
  187. package/registry/components/inline-citations.css +126 -0
  188. package/registry/components/inline-citations.tsx +292 -0
  189. package/registry/components/input-content.ts +107 -107
  190. package/registry/components/input-group.tsx +130 -130
  191. package/registry/components/input-otp.tsx +584 -584
  192. package/registry/components/input.tsx +990 -972
  193. package/registry/components/item.tsx +189 -189
  194. package/registry/components/kbd.tsx +246 -151
  195. package/registry/components/label.tsx +78 -78
  196. package/registry/components/menu.tsx +978 -986
  197. package/registry/components/menubar.tsx +226 -226
  198. package/registry/components/modal.tsx +872 -872
  199. package/registry/components/native-select.tsx +205 -205
  200. package/registry/components/navigation-menu.tsx +171 -171
  201. package/registry/components/pagination.css +36 -36
  202. package/registry/components/pagination.tsx +560 -555
  203. package/registry/components/popover.tsx +338 -338
  204. package/registry/components/progress-slider.tsx +216 -216
  205. package/registry/components/radio-card.tsx +676 -677
  206. package/registry/components/radio-group.tsx +570 -570
  207. package/registry/components/resizable.tsx +108 -108
  208. package/registry/components/rolling-count.css +37 -0
  209. package/registry/components/rolling-count.tsx +110 -0
  210. package/registry/components/scroll-area.tsx +91 -91
  211. package/registry/components/segmented-control.tsx +87 -87
  212. package/registry/components/select-content.tsx +218 -218
  213. package/registry/components/select.tsx +937 -932
  214. package/registry/components/sheet.tsx +305 -305
  215. package/registry/components/shimmer-text.css +69 -0
  216. package/registry/components/shimmer-text.tsx +43 -0
  217. package/registry/components/sidebar.tsx +1433 -358
  218. package/registry/components/skeleton.css +54 -54
  219. package/registry/components/skeleton.tsx +482 -482
  220. package/registry/components/slider.tsx +380 -380
  221. package/registry/components/social-button.tsx +387 -387
  222. package/registry/components/spinner.tsx +78 -78
  223. package/registry/components/streaming-text.css +45 -0
  224. package/registry/components/streaming-text.tsx +165 -0
  225. package/registry/components/table-elements.tsx +128 -128
  226. package/registry/components/tabs.css +222 -218
  227. package/registry/components/tabs.tsx +241 -241
  228. package/registry/components/tag.tsx +423 -423
  229. package/registry/components/task-list.css +328 -0
  230. package/registry/components/task-list.tsx +429 -0
  231. package/registry/components/textarea.tsx +610 -610
  232. package/registry/components/thinking-reasoning.css +178 -0
  233. package/registry/components/thinking-reasoning.tsx +253 -0
  234. package/registry/components/thinking-state.css +8 -0
  235. package/registry/components/thinking-state.tsx +46 -0
  236. package/registry/components/toast.tsx +136 -136
  237. package/registry/components/toggle.tsx +256 -256
  238. package/registry/components/toolbar.tsx +539 -539
  239. package/registry/components/tooltip.tsx +433 -431
  240. package/registry/examples/agent-input-demo.tsx +90 -0
  241. package/registry/examples/alert-demo.tsx +34 -34
  242. package/registry/examples/alert-dialog-demo.tsx +42 -42
  243. package/registry/examples/approval-card-demo.tsx +83 -0
  244. package/registry/examples/aspect-ratio-demo.tsx +11 -11
  245. package/registry/examples/attribute-demo.tsx +65 -65
  246. package/registry/examples/autocomplete-demo.tsx +109 -109
  247. package/registry/examples/avatar-demo.tsx +132 -132
  248. package/registry/examples/badge-demo.tsx +69 -69
  249. package/registry/examples/button-demo.tsx +86 -83
  250. package/registry/examples/button-group-demo.tsx +63 -63
  251. package/registry/examples/card-demo.tsx +86 -86
  252. package/registry/examples/carousel-demo.tsx +43 -43
  253. package/registry/examples/chart-block-demo.tsx +5 -0
  254. package/registry/examples/chart-demo.tsx +18 -33
  255. package/registry/examples/checkbox-demo.tsx +62 -62
  256. package/registry/examples/collapsible-demo.tsx +16 -16
  257. package/registry/examples/collection-filter-bar-demo.tsx +135 -135
  258. package/registry/examples/command-demo.tsx +42 -42
  259. package/registry/examples/command-palette-demo.tsx +235 -236
  260. package/registry/examples/context-menu-demo.tsx +29 -29
  261. package/registry/examples/control-density-contract-demo.tsx +65 -61
  262. package/registry/examples/date-picker-demo.tsx +127 -127
  263. package/registry/examples/divider-demo.tsx +17 -17
  264. package/registry/examples/expanded/activity-heatmap-demo.tsx +29 -29
  265. package/registry/examples/expanded/banner-demo.tsx +48 -48
  266. package/registry/examples/expanded/breadcrumbs-demo.tsx +59 -59
  267. package/registry/examples/expanded/empty-state-demo.tsx +39 -39
  268. package/registry/examples/expanded/file-upload-demo.tsx +60 -60
  269. package/registry/examples/expanded/onboarding-step-demo.tsx +11 -11
  270. package/registry/examples/expanded/slideout-demo.tsx +15 -15
  271. package/registry/examples/expanded/steps-demo.tsx +29 -29
  272. package/registry/examples/expanded/tabs-demo.tsx +13 -13
  273. package/registry/examples/expanded/timeline-demo.tsx +18 -18
  274. package/registry/examples/field-demo.tsx +51 -51
  275. package/registry/examples/hover-card-demo.tsx +23 -23
  276. package/registry/examples/inline-citations-demo.tsx +31 -0
  277. package/registry/examples/input-actions-demo.tsx +36 -0
  278. package/registry/examples/input-demo.tsx +87 -87
  279. package/registry/examples/input-group-demo.tsx +16 -16
  280. package/registry/examples/input-otp-demo.tsx +72 -72
  281. package/registry/examples/item-demo.tsx +46 -46
  282. package/registry/examples/kbd-demo.tsx +12 -11
  283. package/registry/examples/label-demo.tsx +20 -20
  284. package/registry/examples/menu-demo.tsx +122 -122
  285. package/registry/examples/menubar-demo.tsx +34 -34
  286. package/registry/examples/modal-demo.tsx +74 -74
  287. package/registry/examples/native-select-demo.tsx +16 -16
  288. package/registry/examples/navigation-menu-demo.tsx +29 -29
  289. package/registry/examples/pagination-demo.tsx +20 -20
  290. package/registry/examples/popover-demo.tsx +547 -546
  291. package/registry/examples/progress-demo.tsx +37 -37
  292. package/registry/examples/progress-slider-demo.tsx +29 -29
  293. package/registry/examples/radio-card-demo.tsx +51 -51
  294. package/registry/examples/radio-group-demo.tsx +62 -62
  295. package/registry/examples/resizable-demo.tsx +22 -22
  296. package/registry/examples/results-toolbar-demo.tsx +44 -44
  297. package/registry/examples/rolling-count-demo.tsx +39 -0
  298. package/registry/examples/scroll-area-demo.tsx +15 -15
  299. package/registry/examples/segmented-control-demo.tsx +39 -39
  300. package/registry/examples/select-demo.tsx +87 -87
  301. package/registry/examples/sheet-demo.tsx +47 -47
  302. package/registry/examples/shimmer-text-demo.tsx +20 -0
  303. package/registry/examples/sidebar-demo.tsx +101 -57
  304. package/registry/examples/slider-demo.tsx +31 -31
  305. package/registry/examples/social-button-demo.tsx +61 -61
  306. package/registry/examples/spinner-demo.tsx +59 -59
  307. package/registry/examples/streaming-text-demo.tsx +28 -0
  308. package/registry/examples/tabs-demo.tsx +13 -13
  309. package/registry/examples/tag-demo.tsx +29 -29
  310. package/registry/examples/task-list-demo.tsx +74 -0
  311. package/registry/examples/textarea-demo.tsx +79 -79
  312. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  313. package/registry/examples/thinking-state-demo.tsx +13 -0
  314. package/registry/examples/toast-demo.tsx +35 -35
  315. package/registry/examples/toggle-demo.tsx +59 -59
  316. package/registry/examples/tooltip-demo.tsx +115 -115
  317. package/registry/guidance/USAGE.md +27 -27
  318. package/registry/guidance/charts.LICENSE.txt +24 -0
  319. package/registry/hooks/use-controllable-state.ts +33 -0
  320. package/registry/hooks/use-direction.ts +207 -207
  321. package/registry/hooks/use-listbox-navigation.ts +73 -0
  322. package/registry/hooks/use-motion-end.ts +55 -0
  323. package/registry/hooks/use-reduced-motion.ts +28 -0
  324. package/registry/index.json +1634 -184
  325. package/registry/styles/globals.css +1462 -1570
  326. package/registry.json +364 -10
  327. package/schema/config.schema.json +51 -51
  328. package/schema/registry.schema.json +194 -190
  329. package/schema/tokens.schema.json +167 -163
  330. package/src/cli/command-line.js +194 -194
  331. package/src/cli/config-editors.js +9 -9
  332. package/src/cli/doctor.js +399 -399
  333. package/src/cli/file-transaction.js +302 -295
  334. package/src/cli/global-css.js +153 -153
  335. package/src/cli/index.js +1002 -1009
  336. package/src/cli/install-plan.js +592 -592
  337. package/src/cli/install-receipt.js +171 -171
  338. package/src/cli/jsonc.js +203 -203
  339. package/src/cli/package-install-state.js +254 -254
  340. package/src/cli/package-manager.js +238 -238
  341. package/src/cli/package-plan.js +185 -185
  342. package/src/cli/package-version.js +97 -97
  343. package/src/cli/registry-query.js +207 -207
  344. package/src/cli/reporting.js +55 -55
  345. package/src/cli/schema-validation.js +19 -19
  346. package/src/cli/source-scanner.js +686 -686
  347. package/src/cli/vite-config.js +478 -478
  348. package/tokens/banhaten.tokens.json +458 -224
@@ -1,1071 +1,1092 @@
1
- import { useMemo, useState, type CSSProperties, type ReactNode } from "react";
2
- import {
3
- ArrowUpDownIcon,
4
- EditIcon as LucideEditIcon,
5
- EllipsisIcon,
6
- EyeIcon,
7
- FileIcon as LucideFileIcon,
8
- StarIcon,
9
- Trash2Icon,
10
- } from "lucide-react";
11
- import { Pagination, type PaginationPage, type PaginationProps } from "@/components/ui/pagination";
12
- import { Spinner } from "@/components/ui/spinner";
13
- import { Avatar as AvatarPrimitive, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
14
- import { Badge } from "@/components/ui/badge";
15
- import { Button } from "@/components/ui/button";
16
- import { Checkbox } from "@/components/ui/checkbox";
17
- import { Progress as ProgressPrimitive } from "@/components/ui/progress";
18
- import { Tag } from "@/components/ui/tag";
19
- import { Toggle } from "@/components/ui/toggle";
20
- import {
21
- Toolbar,
22
- ToolbarBadge,
23
- ToolbarButton,
24
- ToolbarSearch,
25
- ToolbarSection,
26
- ToolbarText,
27
- type ToolbarButtonProps,
28
- } from "@/components/ui/toolbar";
29
- import "./table.css";
30
-
31
- export type TableDirection = "ltr" | "rtl";
32
- export type TableSize = "sm" | "lg";
33
- export type TableRowState = "default" | "selected" | "disabled";
34
- export type TableSortDirection = "asc" | "desc";
35
- export type TableSortValue = string | number | boolean | Date | null | undefined;
36
- export type TableSortState = {
37
- columnId: string;
38
- direction: TableSortDirection;
39
- } | null;
40
-
41
- export type TableRowBase = {
42
- id: string;
43
- state?: TableRowState;
44
- };
45
-
46
- export type TableColumn<Row extends TableRowBase> = {
47
- align?: "start" | "center" | "end";
48
- header?: string;
49
- id: string;
50
- item?: TableItemFactory<Row>;
51
- kind?: "data" | "selection" | "actions";
52
- minWidth?: number;
53
- renderCell?: (row: Row) => ReactNode;
54
- searchValue?: (row: Row) => string;
55
- sortable?: boolean;
56
- sortValue?: (row: Row) => TableSortValue;
57
- width?: number | "fill";
58
- };
59
-
60
- export type TableItemFactory<Row extends TableRowBase> = (row: Row) => TableItem;
61
-
62
- export type BadgeItem = {
63
- dot?: boolean;
64
- label: string;
65
- tone?: "blue" | "fuchsia" | "amber" | "neutral" | "success" | "warning" | "danger";
66
- };
67
-
68
- export type AvatarItem = {
69
- alt: string;
70
- src?: string;
71
- };
72
-
73
- export type TableAction = {
74
- disabled?: boolean;
75
- icon: "view" | "edit" | "delete" | "more";
76
- label: string;
77
- onAction?: () => void;
78
- };
79
-
80
- export type TableItem =
81
- | { type: "checkbox"; ariaLabel?: string; checked: boolean; disabled?: boolean; indeterminate?: boolean; onCheckedChange?: (checked: boolean) => void }
82
- | { type: "text"; tone?: "default" | "subtle"; value: string; weight?: "regular" | "medium" }
83
- | { type: "avatarText"; caption?: string; name: string; src?: string; status?: "top" | "bottom" }
84
- | { type: "rating"; max?: 5; value: number }
85
- | { type: "progress"; label?: string; tone?: "success" | "brand"; value: number }
86
- | { type: "toggle"; ariaLabel?: string; checked: boolean; disabled?: boolean; onCheckedChange?: (checked: boolean) => void }
87
- | { type: "badges"; items: BadgeItem[]; maxVisible?: number }
88
- | { type: "onlyAvatar"; alt: string; src?: string }
89
- | { type: "avatarStack"; addLabel?: string; avatars: AvatarItem[]; canAdd?: boolean; count?: number; onAdd?: () => void }
90
- | { type: "tags"; items: string[]; maxVisible?: number }
91
- | { type: "file"; label?: string }
92
- | { type: "brandLogoText"; label: string; logoSrc?: string }
93
- | { type: "payment"; brand: string; label?: string }
94
- | { type: "image"; label: string; src: string }
95
- | { type: "action"; icon: TableAction["icon"]; label: string; onAction?: () => void }
96
- | { type: "actionGroup"; actions: TableAction[] };
97
-
98
- export type TableProps<Row extends TableRowBase> = {
99
- columns: TableColumn<Row>[];
100
- dir?: TableDirection;
101
- onSelectedRowIdsChange?: (rowIds: string[]) => void;
102
- onSortChange?: (sort: TableSortState) => void;
103
- rows: Row[];
104
- selectedRowIds?: string[];
105
- size?: TableSize;
106
- sort?: TableSortState;
107
- };
108
-
109
- export type DataTableAction = {
110
- ariaLabel?: string;
111
- disabled?: boolean;
112
- label: string;
113
- onAction?: () => void;
114
- variant?: ToolbarButtonProps["variant"];
115
- };
116
-
117
- export type DataTableFilter<Row extends TableRowBase> = {
118
- active?: boolean;
119
- id: string;
120
- label: string;
121
- onAction?: () => void;
122
- predicate?: (row: Row) => boolean;
123
- value?: string;
124
- };
125
-
126
- export type DataTableSearch<Row extends TableRowBase> =
127
- | boolean
128
- | {
129
- defaultValue?: string;
130
- disabled?: boolean;
131
- getRowText?: (row: Row) => string;
132
- label?: string;
133
- onValueChange?: (value: string) => void;
134
- placeholder?: string;
135
- value?: string;
136
- };
137
-
138
- export type DataTablePagination = {
139
- ariaLabel?: string;
140
- defaultPage?: number;
141
- manual?: boolean;
142
- onPageChange?: (page: number) => void;
143
- page?: number;
144
- pageSize?: number;
145
- pages?: PaginationPage[];
146
- showCaption?: boolean;
147
- totalRows?: number;
148
- type?: PaginationProps["type"];
149
- variant?: PaginationProps["variant"];
150
- };
151
-
152
- export type DataTableState = {
153
- action?: DataTableAction;
154
- description?: ReactNode;
155
- title?: ReactNode;
156
- };
157
-
158
- export type DataTableLabels = {
159
- emptyDescription: ReactNode;
160
- emptyTitle: ReactNode;
161
- errorDescription: ReactNode;
162
- errorTitle: ReactNode;
163
- loadingDescription: ReactNode;
164
- loadingTitle: ReactNode;
165
- resultsSummary: (summary: { from: number; to: number; total: number }) => ReactNode;
166
- searchPlaceholder: string;
167
- selectedCount: (count: number) => ReactNode;
168
- };
169
-
170
- export type DataTableProps<Row extends TableRowBase> = Omit<TableProps<Row>, "onSortChange" | "rows" | "sort"> & {
171
- actions?: DataTableAction[];
172
- bulkActions?: DataTableAction[];
173
- className?: string;
174
- defaultSort?: TableSortState;
175
- description?: ReactNode;
176
- emptyState?: DataTableState;
177
- error?: boolean | string | DataTableState;
178
- filters?: DataTableFilter<Row>[];
179
- getRowSearchText?: (row: Row) => string;
180
- labels?: Partial<DataTableLabels>;
181
- loading?: boolean;
182
- onSortChange?: (sort: TableSortState) => void;
183
- pagination?: false | DataTablePagination;
184
- rows: Row[];
185
- search?: DataTableSearch<Row>;
186
- sort?: TableSortState;
187
- title?: ReactNode;
188
- };
189
-
190
- function cx(...classes: Array<string | false | undefined>) {
191
- return classes.filter(Boolean).join(" ");
192
- }
193
-
194
- export function Table<Row extends TableRowBase>({
195
- columns,
196
- dir,
197
- onSelectedRowIdsChange,
198
- onSortChange,
199
- rows,
200
- selectedRowIds = [],
201
- size = "sm",
202
- sort = null,
203
- }: TableProps<Row>) {
204
- const selectableRows = rows.filter((row) => row.state !== "disabled");
205
- const allSelected = selectableRows.length > 0 && selectableRows.every((row) => selectedRowIds.includes(row.id));
206
- const someSelected = selectableRows.some((row) => selectedRowIds.includes(row.id)) && !allSelected;
207
-
208
- function toggleAll(checked: boolean) {
209
- onSelectedRowIdsChange?.(checked ? selectableRows.map((row) => row.id) : []);
210
- }
211
-
212
- function toggleRow(row: Row, checked: boolean) {
213
- if (row.state === "disabled") return;
214
- const next = checked
215
- ? Array.from(new Set([...selectedRowIds, row.id]))
216
- : selectedRowIds.filter((rowId) => rowId !== row.id);
217
- onSelectedRowIdsChange?.(next);
218
- }
219
-
220
- return (
221
- <div
222
- aria-label="Scrollable table"
223
- className={`ds-table-wrap ds-table-wrap--${size}`}
224
- dir={dir}
225
- tabIndex={0}
226
- >
227
- <table className="ds-table">
228
- <thead>
229
- <tr>
230
- {columns.map((column) => (
231
- <th
232
- aria-sort={column.sortable ? getAriaSort(sort, column.id) : undefined}
233
- className={columnClass(column)}
234
- key={column.id}
235
- style={columnStyle(column)}
236
- scope="col"
237
- >
238
- {column.kind === "selection" ? (
239
- <SelectionCheckbox
240
- checked={allSelected}
241
- indeterminate={someSelected}
242
- label="Select all rows"
243
- onChange={toggleAll}
244
- />
245
- ) : column.header ? (
246
- column.sortable ? (
247
- onSortChange ? (
248
- <Button
249
- aria-label={`Sort by ${column.header}`}
250
- className={cx(
251
- "ds-table__sort",
252
- sort?.columnId === column.id && `ds-table__sort--${sort.direction}`,
253
- )}
254
- onClick={() => onSortChange(getNextSort(sort, column.id))}
255
- size="xs"
256
- type="button"
257
- variant="ghost"
258
- >
259
- <span>{column.header}</span>
260
- <SortIcon direction={sort?.columnId === column.id ? sort.direction : undefined} />
261
- </Button>
262
- ) : (
263
- <span className="ds-table__sort ds-table__sort--static">
264
- <span>{column.header}</span>
265
- <SortIcon />
266
- </span>
267
- )
268
- ) : (
269
- column.header
270
- )
271
- ) : (
272
- <span className="sr-only">
273
- {column.kind === "actions" ? "Actions" : column.id}
274
- </span>
275
- )}
276
- </th>
277
- ))}
278
- </tr>
279
- </thead>
280
- <tbody>
281
- {rows.map((row) => {
282
- const selected = selectedRowIds.includes(row.id);
283
- const state = row.state === "disabled" ? "disabled" : selected ? "selected" : row.state ?? "default";
284
-
285
- return (
286
- <tr aria-selected={selected || undefined} className={`ds-table__row ds-table__row--${state}`} key={row.id}>
287
- {columns.map((column) => (
288
- <td className={columnClass(column)} key={column.id} style={columnStyle(column)}>
289
- {column.kind === "selection" ? (
290
- <SelectionCheckbox
291
- checked={selected}
292
- disabled={row.state === "disabled"}
293
- label={`Select row ${row.id}`}
294
- onChange={(checked) => toggleRow(row, checked)}
295
- />
296
- ) : column.renderCell ? (
297
- column.renderCell(row)
298
- ) : column.item ? (
299
- renderTableItem(column.item(row), size)
300
- ) : null}
301
- </td>
302
- ))}
303
- </tr>
304
- );
305
- })}
306
- </tbody>
307
- </table>
308
- </div>
309
- );
310
- }
311
-
312
- const defaultDataTablePagination: DataTablePagination = {};
313
-
314
- const defaultDataTableLabels: DataTableLabels = {
315
- emptyDescription: "Try adjusting the search or filters.",
316
- emptyTitle: "No rows found",
317
- errorDescription: "Refresh the table or try again later.",
318
- errorTitle: "Table unavailable",
319
- loadingDescription: "Please wait while the table updates.",
320
- loadingTitle: "Loading rows",
321
- resultsSummary: ({ from, to, total }) => (total === 0 ? "Showing 0 rows" : `Showing ${from} to ${to} of ${total} rows`),
322
- searchPlaceholder: "Search rows...",
323
- selectedCount: (count) => `${count} selected`,
324
- };
325
-
326
- export function DataTable<Row extends TableRowBase>({
327
- actions = [],
328
- bulkActions = [],
329
- className,
330
- columns,
331
- defaultSort = null,
332
- description,
333
- dir,
334
- emptyState,
335
- error = false,
336
- filters = [],
337
- getRowSearchText,
338
- labels: labelOverrides,
339
- loading = false,
340
- onSelectedRowIdsChange,
341
- onSortChange,
342
- pagination = defaultDataTablePagination,
343
- rows,
344
- search = true,
345
- selectedRowIds = [],
346
- size = "sm",
347
- sort,
348
- title,
349
- }: DataTableProps<Row>) {
350
- const labels: DataTableLabels = { ...defaultDataTableLabels, ...labelOverrides };
351
- const searchConfig = search === true ? {} : search && typeof search === "object" ? search : null;
352
- const paginationConfig = pagination === false ? null : pagination;
353
- const [uncontrolledSearch, setUncontrolledSearch] = useState(() => searchConfig?.defaultValue ?? "");
354
- const [uncontrolledSort, setUncontrolledSort] = useState<TableSortState>(() => sort ?? defaultSort);
355
- const [uncontrolledPage, setUncontrolledPage] = useState(() => paginationConfig?.page ?? paginationConfig?.defaultPage ?? 1);
356
- const selectedSort = sort !== undefined ? sort : uncontrolledSort;
357
- const searchValue = searchConfig?.value ?? uncontrolledSearch;
358
- const normalizedSearchValue = searchValue.trim().toLowerCase();
359
-
360
- const processedRows = useMemo(() => {
361
- let nextRows = rows;
362
-
363
- if (normalizedSearchValue) {
364
- nextRows = nextRows.filter((row) =>
365
- getDataTableSearchText({
366
- columns,
367
- getRowSearchText: getRowSearchText ?? searchConfig?.getRowText,
368
- row,
369
- })
370
- .toLowerCase()
371
- .includes(normalizedSearchValue),
372
- );
373
- }
374
-
375
- for (const filter of filters) {
376
- if (filter.active && filter.predicate) {
377
- nextRows = nextRows.filter(filter.predicate);
378
- }
379
- }
380
-
381
- return sortDataTableRows(nextRows, columns, selectedSort);
382
- }, [columns, filters, getRowSearchText, normalizedSearchValue, rows, searchConfig?.getRowText, selectedSort]);
383
-
384
- const pageSize = Math.max(1, paginationConfig?.pageSize ?? 10);
385
- const totalRows = paginationConfig?.totalRows ?? processedRows.length;
386
- const pageCount = Math.max(1, Math.ceil(Math.max(totalRows, 0) / pageSize));
387
- const currentPage = paginationConfig ? clampNumber(paginationConfig.page ?? uncontrolledPage, 1, pageCount) : 1;
388
- const startIndex = (currentPage - 1) * pageSize;
389
- const displayedRows = paginationConfig?.manual ? processedRows : paginationConfig ? processedRows.slice(startIndex, startIndex + pageSize) : processedRows;
390
- const visibleFrom = totalRows === 0 ? 0 : startIndex + 1;
391
- const visibleTo = paginationConfig?.manual ? Math.min(currentPage * pageSize, totalRows) : Math.min(startIndex + displayedRows.length, totalRows);
392
- const hasHeader = Boolean(title || description || actions.length > 0);
393
- const hasToolbar = Boolean(searchConfig || filters.length > 0 || selectedRowIds.length > 0 || bulkActions.length > 0);
394
- const activeBulkActions = selectedRowIds.length > 0 ? bulkActions : [];
395
- const state = getDataTableState({
396
- emptyState,
397
- error,
398
- labels,
399
- loading,
400
- rowCount: processedRows.length,
401
- });
402
-
403
- function setDataTablePage(nextPage: number) {
404
- if (!paginationConfig) return;
405
-
406
- const safePage = clampNumber(nextPage, 1, pageCount);
407
- if (paginationConfig.page === undefined) {
408
- setUncontrolledPage(safePage);
409
- }
410
- paginationConfig.onPageChange?.(safePage);
411
- }
412
-
413
- function resetPagination() {
414
- setDataTablePage(1);
415
- }
416
-
417
- function handleSearchChange(value: string) {
418
- if (searchConfig?.value === undefined) {
419
- setUncontrolledSearch(value);
420
- }
421
- searchConfig?.onValueChange?.(value);
422
- resetPagination();
423
- }
424
-
425
- function handleSortChange(nextSort: TableSortState) {
426
- if (sort === undefined) {
427
- setUncontrolledSort(nextSort);
428
- }
429
- onSortChange?.(nextSort);
430
- resetPagination();
431
- }
432
-
433
- return (
434
- <section className={cx("ds-data-table", className)} dir={dir}>
435
- {hasHeader && (
436
- <header className="ds-data-table__header">
437
- <div className="ds-data-table__heading">
438
- {title && <h2>{title}</h2>}
439
- {description && <p>{description}</p>}
440
- </div>
441
- {actions.length > 0 && (
442
- <div className="ds-data-table__actions">
443
- {actions.map((action) => (
444
- <DataTableActionButton action={action} key={action.label} />
445
- ))}
446
- </div>
447
- )}
448
- </header>
449
- )}
450
-
451
- {hasToolbar && (
452
- <Toolbar className="ds-data-table__toolbar" dir={dir} layout="split" wrap>
453
- <ToolbarSection grow wrap>
454
- {searchConfig && (
455
- <ToolbarSearch
456
- aria-label={searchConfig.label ?? "Search table rows"}
457
- disabled={searchConfig.disabled}
458
- onChange={(event) => handleSearchChange(event.currentTarget.value)}
459
- placeholder={searchConfig.placeholder ?? labels.searchPlaceholder}
460
- value={searchValue}
461
- width="full"
462
- />
463
- )}
464
- {filters.map((filter) => (
465
- <ToolbarButton
466
- aria-pressed={filter.active || undefined}
467
- key={filter.id}
468
- onClick={() => {
469
- filter.onAction?.();
470
- resetPagination();
471
- }}
472
- size="sm"
473
- variant={filter.active ? "default" : "soft"}
474
- >
475
- {filter.value ? `${filter.label}: ${filter.value}` : filter.label}
476
- </ToolbarButton>
477
- ))}
478
- </ToolbarSection>
479
- {(selectedRowIds.length > 0 || activeBulkActions.length > 0) && (
480
- <ToolbarSection align="end" wrap>
481
- {selectedRowIds.length > 0 && <ToolbarBadge variant="brand">{labels.selectedCount(selectedRowIds.length)}</ToolbarBadge>}
482
- {activeBulkActions.map((action) => (
483
- <DataTableActionButton action={action} key={action.label} />
484
- ))}
485
- </ToolbarSection>
486
- )}
487
- </Toolbar>
488
- )}
489
-
490
- <div className="ds-data-table__body">
491
- <Table
492
- columns={columns}
493
- dir={dir}
494
- onSelectedRowIdsChange={onSelectedRowIdsChange}
495
- onSortChange={handleSortChange}
496
- rows={state ? [] : displayedRows}
497
- selectedRowIds={selectedRowIds}
498
- size={size}
499
- sort={selectedSort}
500
- />
501
- {state && <DataTableStateView loading={loading} state={state} />}
502
- </div>
503
-
504
- {paginationConfig && (
505
- <footer className="ds-data-table__footer">
506
- <ToolbarText>{labels.resultsSummary({ from: visibleFrom, to: visibleTo, total: totalRows })}</ToolbarText>
507
- <Pagination
508
- aria-label={
509
- paginationConfig.ariaLabel ??
510
- (typeof title === "string" ? `${title} pagination` : "Table pagination")
511
- }
512
- dir={dir}
513
- nextDisabled={currentPage >= pageCount}
514
- onPageChange={setDataTablePage}
515
- page={currentPage}
516
- pages={paginationConfig.pages ?? getDataTablePaginationPages(currentPage, pageCount)}
517
- previousDisabled={currentPage <= 1}
518
- showCaption={paginationConfig.showCaption ?? false}
519
- summary={labels.resultsSummary({ from: visibleFrom, to: visibleTo, total: totalRows })}
520
- type={paginationConfig.type ?? "numeric"}
521
- variant={paginationConfig.variant ?? "soft"}
522
- />
523
- </footer>
524
- )}
525
- </section>
526
- );
527
- }
528
-
529
- function columnClass<Row extends TableRowBase>(column: TableColumn<Row>) {
530
- return cx(
531
- "ds-table__cell",
532
- column.width === "fill" && "ds-table__cell--fill",
533
- column.align && `ds-table__cell--${column.align}`,
534
- column.kind === "selection" && "ds-table__cell--selection",
535
- column.kind === "actions" && "ds-table__cell--actions",
536
- );
537
- }
538
-
539
- function columnStyle<Row extends TableRowBase>(column: TableColumn<Row>): CSSProperties {
540
- return {
541
- minWidth: column.minWidth,
542
- width: typeof column.width === "number" ? column.width : undefined,
543
- };
544
- }
545
-
546
- type ResolvedDataTableState = DataTableState & {
547
- tone: "danger" | "default" | "loading";
548
- };
549
-
550
- function DataTableActionButton({ action }: { action: DataTableAction }) {
551
- return (
552
- <ToolbarButton
553
- aria-label={action.ariaLabel}
554
- disabled={action.disabled}
555
- onClick={action.onAction}
556
- size="sm"
557
- variant={action.variant ?? "soft"}
558
- >
559
- {action.label}
560
- </ToolbarButton>
561
- );
562
- }
563
-
564
- function DataTableStateView({ loading, state }: { loading: boolean; state: ResolvedDataTableState }) {
565
- return (
566
- <div className={`ds-data-table__state ds-data-table__state--${state.tone}`} role={state.tone === "danger" ? "alert" : "status"}>
567
- <div className="ds-data-table__state-copy">
568
- {loading && <Spinner className="ds-data-table__spinner" />}
569
- {state.title && <strong>{state.title}</strong>}
570
- {state.description && <p>{state.description}</p>}
571
- </div>
572
- {state.action && (
573
- <div className="ds-data-table__state-action">
574
- <DataTableActionButton action={state.action} />
575
- </div>
576
- )}
577
- </div>
578
- );
579
- }
580
-
581
- function getDataTableState({
582
- emptyState,
583
- error,
584
- labels,
585
- loading,
586
- rowCount,
587
- }: {
588
- emptyState?: DataTableState;
589
- error: boolean | string | DataTableState;
590
- labels: DataTableLabels;
591
- loading: boolean;
592
- rowCount: number;
593
- }): ResolvedDataTableState | null {
594
- if (loading) {
595
- return {
596
- description: labels.loadingDescription,
597
- title: labels.loadingTitle,
598
- tone: "loading",
599
- };
600
- }
601
-
602
- if (error) {
603
- if (typeof error === "string") {
604
- return {
605
- description: error,
606
- title: labels.errorTitle,
607
- tone: "danger",
608
- };
609
- }
610
-
611
- if (typeof error === "object") {
612
- return {
613
- action: error.action,
614
- description: error.description ?? labels.errorDescription,
615
- title: error.title ?? labels.errorTitle,
616
- tone: "danger",
617
- };
618
- }
619
-
620
- return {
621
- description: labels.errorDescription,
622
- title: labels.errorTitle,
623
- tone: "danger",
624
- };
625
- }
626
-
627
- if (rowCount === 0) {
628
- return {
629
- action: emptyState?.action,
630
- description: emptyState?.description ?? labels.emptyDescription,
631
- title: emptyState?.title ?? labels.emptyTitle,
632
- tone: "default",
633
- };
634
- }
635
-
636
- return null;
637
- }
638
-
639
- function getDataTableSearchText<Row extends TableRowBase>({
640
- columns,
641
- getRowSearchText,
642
- row,
643
- }: {
644
- columns: TableColumn<Row>[];
645
- getRowSearchText?: (row: Row) => string;
646
- row: Row;
647
- }) {
648
- const values = [row.id, getRowSearchText?.(row)];
649
-
650
- for (const column of columns) {
651
- const columnValue = column.searchValue?.(row) ?? (column.item ? getTableItemText(column.item(row)) : undefined);
652
- if (columnValue) values.push(columnValue);
653
- }
654
-
655
- return values.filter(Boolean).join(" ");
656
- }
657
-
658
- function sortDataTableRows<Row extends TableRowBase>(rows: Row[], columns: TableColumn<Row>[], sort: TableSortState) {
659
- if (!sort) return rows;
660
-
661
- const column = columns.find((item) => item.id === sort.columnId);
662
- if (!column) return rows;
663
-
664
- const direction = sort.direction === "asc" ? 1 : -1;
665
-
666
- return [...rows].sort((left, right) => {
667
- const result = compareTableSortValues(getColumnSortValue(column, left), getColumnSortValue(column, right));
668
- return result * direction;
669
- });
670
- }
671
-
672
- function getColumnSortValue<Row extends TableRowBase>(column: TableColumn<Row>, row: Row): TableSortValue {
673
- if (column.sortValue) return column.sortValue(row);
674
- if (column.item) return getTableItemSortValue(column.item(row));
675
- return undefined;
676
- }
677
-
678
- function compareTableSortValues(left: TableSortValue, right: TableSortValue) {
679
- if (left === right) return 0;
680
- if (left === null || left === undefined) return 1;
681
- if (right === null || right === undefined) return -1;
682
-
683
- const normalizedLeft = normalizeTableSortValue(left);
684
- const normalizedRight = normalizeTableSortValue(right);
685
-
686
- if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
687
- return normalizedLeft - normalizedRight;
688
- }
689
-
690
- return String(normalizedLeft).localeCompare(String(normalizedRight), undefined, {
691
- numeric: true,
692
- sensitivity: "base",
693
- });
694
- }
695
-
696
- function normalizeTableSortValue(value: Exclude<TableSortValue, null | undefined>) {
697
- if (value instanceof Date) return value.getTime();
698
- if (typeof value === "boolean") return value ? 1 : 0;
699
- return value;
700
- }
701
-
702
- function getTableItemSortValue(item: TableItem): TableSortValue {
703
- switch (item.type) {
704
- case "avatarText":
705
- return item.name;
706
- case "badges":
707
- return item.items.map((badge) => badge.label).join(" ");
708
- case "brandLogoText":
709
- return item.label;
710
- case "file":
711
- return item.label;
712
- case "image":
713
- return item.label;
714
- case "payment":
715
- return item.label ?? item.brand;
716
- case "progress":
717
- case "rating":
718
- return item.value;
719
- case "tags":
720
- return item.items.join(" ");
721
- case "text":
722
- return item.value;
723
- case "toggle":
724
- return item.checked;
725
- default:
726
- return undefined;
727
- }
728
- }
729
-
730
- function getTableItemText(item: TableItem) {
731
- const value = getTableItemSortValue(item);
732
- return value === null || value === undefined ? "" : String(value);
733
- }
734
-
735
- function getDataTablePaginationPages(currentPage: number, pageCount: number): PaginationPage[] {
736
- if (pageCount <= 5) {
737
- return Array.from({ length: pageCount }, (_, index) => index + 1);
738
- }
739
-
740
- const pages: PaginationPage[] = [1];
741
- const start = Math.max(2, currentPage - 1);
742
- const end = Math.min(pageCount - 1, currentPage + 1);
743
-
744
- if (start > 2) pages.push("ellipsis");
745
- for (let page = start; page <= end; page += 1) {
746
- pages.push(page);
747
- }
748
- if (end < pageCount - 1) pages.push("ellipsis");
749
- pages.push(pageCount);
750
-
751
- return pages;
752
- }
753
-
754
- function getAriaSort(sort: TableSortState, columnId: string) {
755
- if (sort?.columnId !== columnId) return "none";
756
- return sort.direction === "asc" ? "ascending" : "descending";
757
- }
758
-
759
- function getNextSort(sort: TableSortState, columnId: string): TableSortState {
760
- if (sort?.columnId !== columnId) {
761
- return { columnId, direction: "asc" };
762
- }
763
-
764
- return {
765
- columnId,
766
- direction: sort.direction === "asc" ? "desc" : "asc",
767
- };
768
- }
769
-
770
- function clampNumber(value: number, min: number, max: number) {
771
- if (!Number.isFinite(value)) return min;
772
- return Math.min(max, Math.max(min, Math.floor(value)));
773
- }
774
-
775
- function renderTableItem(item: TableItem, tableSize: TableSize) {
776
- switch (item.type) {
777
- case "checkbox":
778
- return (
779
- <SelectionCheckbox
780
- checked={item.checked}
781
- disabled={item.disabled ?? !item.onCheckedChange}
782
- indeterminate={item.indeterminate}
783
- label={item.ariaLabel ?? "Select row"}
784
- onChange={item.onCheckedChange}
785
- />
786
- );
787
- case "text":
788
- return <span className={`ds-table-item-text ds-table-item-text--${item.tone ?? "default"} ds-table-item-text--${item.weight ?? "regular"}`}>{item.value}</span>;
789
- case "avatarText":
790
- return (
791
- <span className="ds-table-avatar-text">
792
- <Avatar name={item.name} size={tableSize === "lg" ? "md" : "sm"} src={item.src} />
793
- <span>
794
- <strong>{item.name}</strong>
795
- {item.caption && <em>{item.caption}</em>}
796
- </span>
797
- </span>
798
- );
799
- case "rating":
800
- return <Rating value={item.value} max={item.max ?? 5} />;
801
- case "progress":
802
- return <Progress value={item.value} label={item.label} tone={item.tone ?? "success"} />;
803
- case "toggle":
804
- return (
805
- <Toggle
806
- aria-label={item.ariaLabel ?? "Toggle row value"}
807
- checked={item.checked}
808
- disabled={item.disabled ?? !item.onCheckedChange}
809
- onCheckedChange={item.onCheckedChange}
810
- size="sm"
811
- />
812
- );
813
- case "badges":
814
- return <BadgeList items={item.items} maxVisible={item.maxVisible} />;
815
- case "onlyAvatar":
816
- return <Avatar name={item.alt} size={tableSize === "lg" ? "md" : "xs"} src={item.src} />;
817
- case "avatarStack":
818
- return (
819
- <AvatarStack
820
- addLabel={item.addLabel}
821
- avatars={item.avatars}
822
- canAdd={item.canAdd}
823
- count={item.count}
824
- onAdd={item.onAdd}
825
- size={tableSize}
826
- />
827
- );
828
- case "tags":
829
- return <TagList items={item.items} maxVisible={item.maxVisible} />;
830
- case "file":
831
- return item.label ? <IconLabel icon={<FileIcon />} label={item.label} /> : null;
832
- case "brandLogoText":
833
- return <IconLabel icon={item.logoSrc ? <img alt="" src={item.logoSrc} /> : <BrandIcon />} label={item.label} />;
834
- case "payment":
835
- return <IconLabel icon={<PaymentIcon />} label={item.label ?? item.brand} />;
836
- case "image":
837
- return <IconLabel icon={<img alt="" src={item.src} />} label={item.label} />;
838
- case "action":
839
- return <IconButton icon={item.icon} label={item.label} onClick={item.onAction} />;
840
- case "actionGroup":
841
- return (
842
- <span className="ds-table-action-group">
843
- {item.actions.map((action) => (
844
- <IconButton disabled={action.disabled} key={action.label} icon={action.icon} label={action.label} onClick={action.onAction} />
845
- ))}
846
- </span>
847
- );
848
- default:
849
- return null;
850
- }
851
- }
852
-
853
- function SelectionCheckbox({
854
- checked,
855
- disabled = false,
856
- indeterminate = false,
857
- label,
858
- onChange,
859
- }: {
860
- checked: boolean;
861
- disabled?: boolean;
862
- indeterminate?: boolean;
863
- label: string;
864
- onChange?: (checked: boolean) => void;
865
- }) {
866
- return (
867
- <Checkbox
868
- aria-label={label}
869
- checked={indeterminate ? "indeterminate" : checked}
870
- disabled={disabled}
871
- onCheckedChange={(nextChecked) => onChange?.(nextChecked === true)}
872
- />
873
- );
874
- }
875
-
876
- type TableAvatarSize = "xs" | "sm" | "md";
877
-
878
- function Avatar({ name, size, src }: { name: string; size: TableAvatarSize; src?: string }) {
879
- const initials = name
880
- .split(" ")
881
- .map((part) => part[0])
882
- .slice(0, 2)
883
- .join("")
884
- .toUpperCase();
885
-
886
- return (
887
- <AvatarPrimitive className="ds-table-avatar" size={size}>
888
- {src ? <AvatarImage alt="" src={src} /> : null}
889
- <AvatarFallback size={size}>{initials}</AvatarFallback>
890
- </AvatarPrimitive>
891
- );
892
- }
893
-
894
- function AvatarStack({
895
- addLabel = "Add person",
896
- avatars,
897
- canAdd,
898
- count,
899
- onAdd,
900
- size,
901
- }: {
902
- addLabel?: string;
903
- avatars: AvatarItem[];
904
- canAdd?: boolean;
905
- count?: number;
906
- onAdd?: () => void;
907
- size: TableSize;
908
- }) {
909
- return (
910
- <span className={`ds-table-avatar-stack ds-table-avatar-stack--${size}`}>
911
- {avatars.slice(0, 3).map((avatar) => (
912
- <Avatar key={avatar.alt} name={avatar.alt} size={size === "lg" ? "md" : "xs"} src={avatar.src} />
913
- ))}
914
- {count ? <span className="ds-table-avatar ds-table-avatar--count">+{count}</span> : null}
915
- {canAdd ? (
916
- <Button aria-label={addLabel} disabled={!onAdd} onClick={onAdd} size="icon-xs" type="button" variant="ghost">
917
- +
918
- </Button>
919
- ) : null}
920
- </span>
921
- );
922
- }
923
-
924
- function BadgeList({ items, maxVisible = items.length }: { items: BadgeItem[]; maxVisible?: number }) {
925
- const visible = items.slice(0, maxVisible);
926
- const overflow = items.length - visible.length;
927
-
928
- return (
929
- <span className="ds-table-badges">
930
- {visible.map((item, index) => (
931
- <Badge
932
- badgeStyle="light"
933
- color={badgeColor(item.tone)}
934
- key={`${item.label}-${index}`}
935
- size="xs"
936
- type={item.dot ? "dot" : "default"}
937
- >
938
- {item.label}
939
- </Badge>
940
- ))}
941
- {overflow > 0 && <Badge size="xs">+{overflow}</Badge>}
942
- </span>
943
- );
944
- }
945
-
946
- function TagList({ items, maxVisible = items.length }: { items: string[]; maxVisible?: number }) {
947
- const visible = items.slice(0, maxVisible);
948
- const overflow = items.length - visible.length;
949
-
950
- return (
951
- <span className="ds-table-tags">
952
- {visible.map((item, index) => (
953
- <Tag key={`${item}-${index}`} size="xs">{item}</Tag>
954
- ))}
955
- {overflow > 0 && <Tag size="xs">+{overflow}</Tag>}
956
- </span>
957
- );
958
- }
959
-
960
- function Progress({ label, tone, value }: { label?: string; tone: "success" | "brand"; value: number }) {
961
- return (
962
- <ProgressPrimitive
963
- aria-label={label ?? "Progress"}
964
- className={`ds-table-progress ds-table-progress--${tone}`}
965
- indicator={label}
966
- labelPosition="inline"
967
- size="lg"
968
- value={value}
969
- />
970
- );
971
- }
972
-
973
- function Rating({ max, value }: { max: number; value: number }) {
974
- return (
975
- <span
976
- aria-label={`${value} out of ${max}`}
977
- className="ds-table-rating"
978
- role="img"
979
- >
980
- {Array.from({ length: max }, (_, index) => (
981
- <StarIcon
982
- aria-hidden="true"
983
- className={index < value ? "is-filled" : undefined}
984
- key={index}
985
- strokeWidth="var(--bh-icon-stroke-190)"
986
- />
987
- ))}
988
- </span>
989
- );
990
- }
991
-
992
- function IconLabel({ icon, label }: { icon: ReactNode; label: string }) {
993
- return (
994
- <span className="ds-table-icon-label">
995
- <span aria-hidden="true">{icon}</span>
996
- <span>{label}</span>
997
- </span>
998
- );
999
- }
1000
-
1001
- function IconButton({
1002
- disabled,
1003
- icon,
1004
- label,
1005
- onClick,
1006
- }: {
1007
- disabled?: boolean;
1008
- icon: TableAction["icon"];
1009
- label: string;
1010
- onClick?: () => void;
1011
- }) {
1012
- return (
1013
- <Button aria-label={label} className="ds-table-icon-button" disabled={disabled ?? !onClick} onClick={onClick} size="icon-xs" type="button" variant="ghost">
1014
- {renderActionIcon(icon)}
1015
- </Button>
1016
- );
1017
- }
1018
-
1019
- function badgeColor(tone: BadgeItem["tone"]) {
1020
- if (tone === "success") return "green" as const;
1021
- if (tone === "warning") return "amber" as const;
1022
- return tone ?? "neutral";
1023
- }
1024
-
1025
- function renderActionIcon(icon: TableAction["icon"]) {
1026
- switch (icon) {
1027
- case "view":
1028
- return <ViewIcon />;
1029
- case "edit":
1030
- return <EditIcon />;
1031
- case "delete":
1032
- return <DeleteIcon />;
1033
- case "more":
1034
- default:
1035
- return <MoreIcon />;
1036
- }
1037
- }
1038
-
1039
- function SortIcon({ direction }: { direction?: TableSortDirection }) {
1040
- return (
1041
- <ArrowUpDownIcon aria-hidden="true" data-direction={direction ?? "none"} strokeWidth="var(--bh-icon-stroke-190)" />
1042
- );
1043
- }
1044
-
1045
- function MoreIcon() {
1046
- return <EllipsisIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-225)" />;
1047
- }
1048
-
1049
- function ViewIcon() {
1050
- return <EyeIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1051
- }
1052
-
1053
- function EditIcon() {
1054
- return <LucideEditIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1055
- }
1056
-
1057
- function DeleteIcon() {
1058
- return <Trash2Icon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1059
- }
1060
-
1061
- function FileIcon() {
1062
- return <LucideFileIcon strokeWidth="var(--bh-icon-stroke-190)" />;
1063
- }
1064
-
1065
- function BrandIcon() {
1066
- return <span className="ds-table-brand-icon" />;
1067
- }
1068
-
1069
- function PaymentIcon() {
1070
- return <span className="ds-table-payment-icon" />;
1071
- }
1
+ import { useMemo, useState, type CSSProperties, type ReactNode } from "react";
2
+ import {
3
+ ArrowUpDownIcon,
4
+ EditIcon as LucideEditIcon,
5
+ EllipsisIcon,
6
+ EyeIcon,
7
+ FileIcon as LucideFileIcon,
8
+ StarIcon,
9
+ Trash2Icon,
10
+ } from "lucide-react";
11
+ import { Pagination, type PaginationPage, type PaginationProps } from "@/components/ui/pagination";
12
+ import { Spinner } from "@/components/ui/spinner";
13
+ import { Avatar as AvatarPrimitive, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
14
+ import { Badge } from "@/components/ui/badge";
15
+ import { Button } from "@/components/ui/button";
16
+ import { Checkbox } from "@/components/ui/checkbox";
17
+ import { Progress as ProgressPrimitive } from "@/components/ui/progress";
18
+ import { Tag } from "@/components/ui/tag";
19
+ import { Toggle } from "@/components/ui/toggle";
20
+ import {
21
+ Toolbar,
22
+ ToolbarBadge,
23
+ ToolbarButton,
24
+ ToolbarSearch,
25
+ ToolbarSection,
26
+ ToolbarText,
27
+ type ToolbarButtonProps,
28
+ } from "@/components/ui/toolbar";
29
+ import "./table.css";
30
+
31
+ export type TableDirection = "ltr" | "rtl";
32
+ export type TableSize = "sm" | "lg";
33
+ export type TableRowState = "default" | "selected" | "disabled";
34
+ export type TableSortDirection = "asc" | "desc";
35
+ export type TableSortValue = string | number | boolean | Date | null | undefined;
36
+ export type TableSortState = {
37
+ columnId: string;
38
+ direction: TableSortDirection;
39
+ } | null;
40
+
41
+ export type TableRowBase = {
42
+ id: string;
43
+ state?: TableRowState;
44
+ };
45
+
46
+ export type TableColumn<Row extends TableRowBase> = {
47
+ align?: "start" | "center" | "end";
48
+ header?: string;
49
+ id: string;
50
+ item?: TableItemFactory<Row>;
51
+ kind?: "data" | "selection" | "actions";
52
+ minWidth?: number;
53
+ renderCell?: (row: Row) => ReactNode;
54
+ searchValue?: (row: Row) => string;
55
+ sortable?: boolean;
56
+ sortValue?: (row: Row) => TableSortValue;
57
+ width?: number | "fill";
58
+ };
59
+
60
+ export type TableItemFactory<Row extends TableRowBase> = (row: Row) => TableItem;
61
+
62
+ export type BadgeItem = {
63
+ dot?: boolean;
64
+ label: string;
65
+ tone?: "blue" | "fuchsia" | "amber" | "neutral" | "success" | "warning" | "danger";
66
+ };
67
+
68
+ export type AvatarItem = {
69
+ alt: string;
70
+ src?: string;
71
+ };
72
+
73
+ export type TableAction = {
74
+ disabled?: boolean;
75
+ icon: "view" | "edit" | "delete" | "more";
76
+ label: string;
77
+ onAction?: () => void;
78
+ };
79
+
80
+ export type TableItem =
81
+ | { type: "checkbox"; ariaLabel?: string; checked: boolean; disabled?: boolean; indeterminate?: boolean; onCheckedChange?: (checked: boolean) => void }
82
+ | { type: "text"; tone?: "default" | "subtle"; value: string; weight?: "regular" | "medium" }
83
+ | { type: "avatarText"; caption?: string; name: string; src?: string; status?: "top" | "bottom" }
84
+ | { type: "rating"; max?: 5; value: number }
85
+ | { type: "progress"; label?: string; tone?: "success" | "brand"; value: number }
86
+ | { type: "toggle"; ariaLabel?: string; checked: boolean; disabled?: boolean; onCheckedChange?: (checked: boolean) => void }
87
+ | { type: "badges"; items: BadgeItem[]; maxVisible?: number }
88
+ | { type: "onlyAvatar"; alt: string; src?: string }
89
+ | { type: "avatarStack"; addLabel?: string; avatars: AvatarItem[]; canAdd?: boolean; count?: number; onAdd?: () => void }
90
+ | { type: "tags"; items: string[]; maxVisible?: number }
91
+ | { type: "file"; label?: string }
92
+ | { type: "brandLogoText"; label: string; logoSrc?: string }
93
+ | { type: "payment"; brand: string; label?: string }
94
+ | { type: "image"; label: string; src: string }
95
+ | { type: "action"; icon: TableAction["icon"]; label: string; onAction?: () => void }
96
+ | { type: "actionGroup"; actions: TableAction[] };
97
+
98
+ export type TableProps<Row extends TableRowBase> = {
99
+ columns: TableColumn<Row>[];
100
+ dir?: TableDirection;
101
+ onSelectedRowIdsChange?: (rowIds: string[]) => void;
102
+ onSortChange?: (sort: TableSortState) => void;
103
+ rows: Row[];
104
+ selectedRowIds?: string[];
105
+ size?: TableSize;
106
+ sort?: TableSortState;
107
+ };
108
+
109
+ export type DataTableAction = {
110
+ ariaLabel?: string;
111
+ disabled?: boolean;
112
+ label: string;
113
+ onAction?: () => void;
114
+ variant?: ToolbarButtonProps["variant"];
115
+ };
116
+
117
+ export type DataTableFilter<Row extends TableRowBase> = {
118
+ active?: boolean;
119
+ id: string;
120
+ label: string;
121
+ onAction?: () => void;
122
+ predicate?: (row: Row) => boolean;
123
+ value?: string;
124
+ };
125
+
126
+ export type DataTableSearch<Row extends TableRowBase> =
127
+ | boolean
128
+ | {
129
+ defaultValue?: string;
130
+ disabled?: boolean;
131
+ getRowText?: (row: Row) => string;
132
+ label?: string;
133
+ onValueChange?: (value: string) => void;
134
+ placeholder?: string;
135
+ value?: string;
136
+ };
137
+
138
+ export type DataTablePagination = {
139
+ ariaLabel?: string;
140
+ defaultPage?: number;
141
+ manual?: boolean;
142
+ messages?: PaginationProps["messages"];
143
+ onPageChange?: (page: number) => void;
144
+ page?: number;
145
+ pageSize?: number;
146
+ pages?: PaginationPage[];
147
+ showCaption?: boolean;
148
+ totalRows?: number;
149
+ type?: PaginationProps["type"];
150
+ variant?: PaginationProps["variant"];
151
+ };
152
+
153
+ export type DataTableState = {
154
+ action?: DataTableAction;
155
+ description?: ReactNode;
156
+ title?: ReactNode;
157
+ };
158
+
159
+ export type DataTableLabels = {
160
+ emptyDescription: ReactNode;
161
+ emptyTitle: ReactNode;
162
+ errorDescription: ReactNode;
163
+ errorTitle: ReactNode;
164
+ loadingDescription: ReactNode;
165
+ loadingTitle: ReactNode;
166
+ resultsSummary: (summary: { from: number; to: number; total: number }) => ReactNode;
167
+ searchPlaceholder: string;
168
+ selectedCount: (count: number) => ReactNode;
169
+ };
170
+
171
+ export type DataTableProps<Row extends TableRowBase> = Omit<TableProps<Row>, "onSortChange" | "rows" | "sort"> & {
172
+ actions?: DataTableAction[];
173
+ bulkActions?: DataTableAction[];
174
+ className?: string;
175
+ defaultSort?: TableSortState;
176
+ description?: ReactNode;
177
+ emptyState?: DataTableState;
178
+ error?: boolean | string | DataTableState;
179
+ filters?: DataTableFilter<Row>[];
180
+ getRowSearchText?: (row: Row) => string;
181
+ labels?: Partial<DataTableLabels>;
182
+ loading?: boolean;
183
+ onSortChange?: (sort: TableSortState) => void;
184
+ pagination?: false | DataTablePagination;
185
+ rows: Row[];
186
+ search?: DataTableSearch<Row>;
187
+ sort?: TableSortState;
188
+ title?: ReactNode;
189
+ };
190
+
191
+ function cx(...classes: Array<string | false | undefined>) {
192
+ return classes.filter(Boolean).join(" ");
193
+ }
194
+
195
+ export function Table<Row extends TableRowBase>({
196
+ columns,
197
+ dir,
198
+ onSelectedRowIdsChange,
199
+ onSortChange,
200
+ rows,
201
+ selectedRowIds = [],
202
+ size = "sm",
203
+ sort = null,
204
+ }: TableProps<Row>) {
205
+ const selectableRows = rows.filter((row) => row.state !== "disabled");
206
+ const allSelected = selectableRows.length > 0 && selectableRows.every((row) => selectedRowIds.includes(row.id));
207
+ const someSelected = selectableRows.some((row) => selectedRowIds.includes(row.id)) && !allSelected;
208
+
209
+ function toggleAll(checked: boolean) {
210
+ onSelectedRowIdsChange?.(checked ? selectableRows.map((row) => row.id) : []);
211
+ }
212
+
213
+ function toggleRow(row: Row, checked: boolean) {
214
+ if (row.state === "disabled") return;
215
+ const next = checked
216
+ ? Array.from(new Set([...selectedRowIds, row.id]))
217
+ : selectedRowIds.filter((rowId) => rowId !== row.id);
218
+ onSelectedRowIdsChange?.(next);
219
+ }
220
+
221
+ return (
222
+ <div
223
+ aria-label="Scrollable table"
224
+ className={`ds-table-wrap ds-table-wrap--${size}`}
225
+ dir={dir}
226
+ tabIndex={0}
227
+ >
228
+ <table className="ds-table">
229
+ <thead>
230
+ <tr>
231
+ {columns.map((column) => (
232
+ <th
233
+ aria-sort={column.sortable ? getAriaSort(sort, column.id) : undefined}
234
+ className={columnClass(column)}
235
+ key={column.id}
236
+ style={columnStyle(column)}
237
+ scope="col"
238
+ >
239
+ {column.kind === "selection" ? (
240
+ <SelectionCheckbox
241
+ checked={allSelected}
242
+ indeterminate={someSelected}
243
+ label="Select all rows"
244
+ onChange={toggleAll}
245
+ />
246
+ ) : column.header ? (
247
+ column.sortable ? (
248
+ onSortChange ? (
249
+ <Button
250
+ aria-label={`Sort by ${column.header}`}
251
+ className={cx(
252
+ "ds-table__sort",
253
+ sort?.columnId === column.id && `ds-table__sort--${sort.direction}`,
254
+ )}
255
+ onClick={() => onSortChange(getNextSort(sort, column.id))}
256
+ size="xs"
257
+ type="button"
258
+ variant="ghost"
259
+ >
260
+ <span>{column.header}</span>
261
+ <SortIcon direction={sort?.columnId === column.id ? sort.direction : undefined} />
262
+ </Button>
263
+ ) : (
264
+ <span className="ds-table__sort ds-table__sort--static">
265
+ <span>{column.header}</span>
266
+ <SortIcon />
267
+ </span>
268
+ )
269
+ ) : (
270
+ column.header
271
+ )
272
+ ) : (
273
+ <span className="sr-only">
274
+ {column.kind === "actions" ? "Actions" : column.id}
275
+ </span>
276
+ )}
277
+ </th>
278
+ ))}
279
+ </tr>
280
+ </thead>
281
+ <tbody>
282
+ {rows.map((row) => {
283
+ const selected = selectedRowIds.includes(row.id);
284
+ const state = row.state === "disabled" ? "disabled" : selected ? "selected" : row.state ?? "default";
285
+
286
+ return (
287
+ <tr aria-selected={selected || undefined} className={`ds-table__row ds-table__row--${state}`} key={row.id}>
288
+ {columns.map((column) => (
289
+ <td className={columnClass(column)} key={column.id} style={columnStyle(column)}>
290
+ {column.kind === "selection" ? (
291
+ <SelectionCheckbox
292
+ checked={selected}
293
+ disabled={row.state === "disabled"}
294
+ label={`Select row ${row.id}`}
295
+ onChange={(checked) => toggleRow(row, checked)}
296
+ />
297
+ ) : (
298
+ renderTableCell(column, row, size)
299
+ )}
300
+ </td>
301
+ ))}
302
+ </tr>
303
+ );
304
+ })}
305
+ </tbody>
306
+ </table>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ function renderTableCell<Row extends TableRowBase>(
312
+ column: TableColumn<Row>,
313
+ row: Row,
314
+ size: TableSize,
315
+ ) {
316
+ if (column.renderCell) return column.renderCell(row);
317
+ if (column.item) return renderTableItem(column.item(row), size);
318
+ if ((column.kind ?? "data") !== "data") return null;
319
+ if (!Object.prototype.hasOwnProperty.call(row, column.id)) return null;
320
+
321
+ const value: unknown = Reflect.get(row, column.id);
322
+ if (typeof value !== "string" && typeof value !== "number") return null;
323
+
324
+ return renderTableItem({ type: "text", value: String(value) }, size);
325
+ }
326
+
327
+ const defaultDataTablePagination: DataTablePagination = {};
328
+
329
+ const defaultDataTableLabels: DataTableLabels = {
330
+ emptyDescription: "Try adjusting the search or filters.",
331
+ emptyTitle: "No rows found",
332
+ errorDescription: "Refresh the table or try again later.",
333
+ errorTitle: "Table unavailable",
334
+ loadingDescription: "Please wait while the table updates.",
335
+ loadingTitle: "Loading rows",
336
+ resultsSummary: ({ from, to, total }) => (total === 0 ? "Showing 0 rows" : `Showing ${from} to ${to} of ${total} rows`),
337
+ searchPlaceholder: "Search rows...",
338
+ selectedCount: (count) => `${count} selected`,
339
+ };
340
+
341
+ export function DataTable<Row extends TableRowBase>({
342
+ actions = [],
343
+ bulkActions = [],
344
+ className,
345
+ columns,
346
+ defaultSort = null,
347
+ description,
348
+ dir,
349
+ emptyState,
350
+ error = false,
351
+ filters = [],
352
+ getRowSearchText,
353
+ labels: labelOverrides,
354
+ loading = false,
355
+ onSelectedRowIdsChange,
356
+ onSortChange,
357
+ pagination = defaultDataTablePagination,
358
+ rows,
359
+ search = true,
360
+ selectedRowIds = [],
361
+ size = "sm",
362
+ sort,
363
+ title,
364
+ }: DataTableProps<Row>) {
365
+ const labels: DataTableLabels = { ...defaultDataTableLabels, ...labelOverrides };
366
+ const searchConfig = search === true ? {} : search && typeof search === "object" ? search : null;
367
+ const paginationConfig = pagination === false ? null : pagination;
368
+ const [uncontrolledSearch, setUncontrolledSearch] = useState(() => searchConfig?.defaultValue ?? "");
369
+ const [uncontrolledSort, setUncontrolledSort] = useState<TableSortState>(() => sort ?? defaultSort);
370
+ const [uncontrolledPage, setUncontrolledPage] = useState(() => paginationConfig?.page ?? paginationConfig?.defaultPage ?? 1);
371
+ const selectedSort = sort !== undefined ? sort : uncontrolledSort;
372
+ const searchValue = searchConfig?.value ?? uncontrolledSearch;
373
+ const normalizedSearchValue = searchValue.trim().toLowerCase();
374
+
375
+ const processedRows = useMemo(() => {
376
+ let nextRows = rows;
377
+
378
+ if (normalizedSearchValue) {
379
+ nextRows = nextRows.filter((row) =>
380
+ getDataTableSearchText({
381
+ columns,
382
+ getRowSearchText: getRowSearchText ?? searchConfig?.getRowText,
383
+ row,
384
+ })
385
+ .toLowerCase()
386
+ .includes(normalizedSearchValue),
387
+ );
388
+ }
389
+
390
+ for (const filter of filters) {
391
+ if (filter.active && filter.predicate) {
392
+ nextRows = nextRows.filter(filter.predicate);
393
+ }
394
+ }
395
+
396
+ return sortDataTableRows(nextRows, columns, selectedSort);
397
+ }, [columns, filters, getRowSearchText, normalizedSearchValue, rows, searchConfig?.getRowText, selectedSort]);
398
+
399
+ const pageSize = Math.max(1, paginationConfig?.pageSize ?? 10);
400
+ const totalRows = paginationConfig?.totalRows ?? processedRows.length;
401
+ const pageCount = Math.max(1, Math.ceil(Math.max(totalRows, 0) / pageSize));
402
+ const currentPage = paginationConfig ? clampNumber(paginationConfig.page ?? uncontrolledPage, 1, pageCount) : 1;
403
+ const startIndex = (currentPage - 1) * pageSize;
404
+ const displayedRows = paginationConfig?.manual ? processedRows : paginationConfig ? processedRows.slice(startIndex, startIndex + pageSize) : processedRows;
405
+ const visibleFrom = totalRows === 0 ? 0 : startIndex + 1;
406
+ const visibleTo = paginationConfig?.manual ? Math.min(currentPage * pageSize, totalRows) : Math.min(startIndex + displayedRows.length, totalRows);
407
+ const resultsSummary = paginationConfig
408
+ ? labels.resultsSummary({ from: visibleFrom, to: visibleTo, total: totalRows })
409
+ : null;
410
+ const hasHeader = Boolean(title || description || actions.length > 0);
411
+ const hasToolbar = Boolean(searchConfig || filters.length > 0 || selectedRowIds.length > 0 || bulkActions.length > 0);
412
+ const activeBulkActions = selectedRowIds.length > 0 ? bulkActions : [];
413
+ const state = getDataTableState({
414
+ emptyState,
415
+ error,
416
+ labels,
417
+ loading,
418
+ rowCount: processedRows.length,
419
+ });
420
+
421
+ function setDataTablePage(nextPage: number) {
422
+ if (!paginationConfig) return;
423
+
424
+ const safePage = clampNumber(nextPage, 1, pageCount);
425
+ if (paginationConfig.page === undefined) {
426
+ setUncontrolledPage(safePage);
427
+ }
428
+ paginationConfig.onPageChange?.(safePage);
429
+ }
430
+
431
+ function resetPagination() {
432
+ setDataTablePage(1);
433
+ }
434
+
435
+ function handleSearchChange(value: string) {
436
+ if (searchConfig?.value === undefined) {
437
+ setUncontrolledSearch(value);
438
+ }
439
+ searchConfig?.onValueChange?.(value);
440
+ resetPagination();
441
+ }
442
+
443
+ function handleSortChange(nextSort: TableSortState) {
444
+ if (sort === undefined) {
445
+ setUncontrolledSort(nextSort);
446
+ }
447
+ onSortChange?.(nextSort);
448
+ resetPagination();
449
+ }
450
+
451
+ return (
452
+ <section className={cx("ds-data-table", className)} dir={dir}>
453
+ {hasHeader && (
454
+ <header className="ds-data-table__header">
455
+ <div className="ds-data-table__heading">
456
+ {title && <h2>{title}</h2>}
457
+ {description && <p>{description}</p>}
458
+ </div>
459
+ {actions.length > 0 && (
460
+ <div className="ds-data-table__actions">
461
+ {actions.map((action) => (
462
+ <DataTableActionButton action={action} key={action.label} />
463
+ ))}
464
+ </div>
465
+ )}
466
+ </header>
467
+ )}
468
+
469
+ {hasToolbar && (
470
+ <Toolbar className="ds-data-table__toolbar" dir={dir} layout="split" wrap>
471
+ <ToolbarSection grow wrap>
472
+ {searchConfig && (
473
+ <ToolbarSearch
474
+ aria-label={searchConfig.label ?? "Search table rows"}
475
+ disabled={searchConfig.disabled}
476
+ onChange={(event) => handleSearchChange(event.currentTarget.value)}
477
+ placeholder={searchConfig.placeholder ?? labels.searchPlaceholder}
478
+ value={searchValue}
479
+ width="full"
480
+ />
481
+ )}
482
+ {filters.map((filter) => (
483
+ <ToolbarButton
484
+ aria-pressed={filter.active || undefined}
485
+ key={filter.id}
486
+ onClick={() => {
487
+ filter.onAction?.();
488
+ resetPagination();
489
+ }}
490
+ size="sm"
491
+ variant={filter.active ? "default" : "soft"}
492
+ >
493
+ {filter.value ? `${filter.label}: ${filter.value}` : filter.label}
494
+ </ToolbarButton>
495
+ ))}
496
+ </ToolbarSection>
497
+ {(selectedRowIds.length > 0 || activeBulkActions.length > 0) && (
498
+ <ToolbarSection align="end" wrap>
499
+ {selectedRowIds.length > 0 && <ToolbarBadge variant="brand">{labels.selectedCount(selectedRowIds.length)}</ToolbarBadge>}
500
+ {activeBulkActions.map((action) => (
501
+ <DataTableActionButton action={action} key={action.label} />
502
+ ))}
503
+ </ToolbarSection>
504
+ )}
505
+ </Toolbar>
506
+ )}
507
+
508
+ <div className="ds-data-table__body">
509
+ <Table
510
+ columns={columns}
511
+ dir={dir}
512
+ onSelectedRowIdsChange={onSelectedRowIdsChange}
513
+ onSortChange={handleSortChange}
514
+ rows={state ? [] : displayedRows}
515
+ selectedRowIds={selectedRowIds}
516
+ size={size}
517
+ sort={selectedSort}
518
+ />
519
+ {state && <DataTableStateView loading={loading} state={state} />}
520
+ </div>
521
+
522
+ {paginationConfig && (
523
+ <footer className="ds-data-table__footer">
524
+ <ToolbarText>{resultsSummary}</ToolbarText>
525
+ <Pagination
526
+ aria-label={
527
+ paginationConfig.ariaLabel ??
528
+ paginationConfig.messages?.navigation ??
529
+ (typeof title === "string" ? `${title} pagination` : "Table pagination")
530
+ }
531
+ dir={dir}
532
+ messages={paginationConfig.messages}
533
+ nextDisabled={currentPage >= pageCount}
534
+ onPageChange={setDataTablePage}
535
+ page={currentPage}
536
+ pages={paginationConfig.pages ?? getDataTablePaginationPages(currentPage, pageCount)}
537
+ previousDisabled={currentPage <= 1}
538
+ caption={resultsSummary}
539
+ showCaption={paginationConfig.showCaption ?? false}
540
+ summary={resultsSummary}
541
+ type={paginationConfig.type ?? "numeric"}
542
+ variant={paginationConfig.variant ?? "soft"}
543
+ />
544
+ </footer>
545
+ )}
546
+ </section>
547
+ );
548
+ }
549
+
550
+ function columnClass<Row extends TableRowBase>(column: TableColumn<Row>) {
551
+ return cx(
552
+ "ds-table__cell",
553
+ column.width === "fill" && "ds-table__cell--fill",
554
+ column.align && `ds-table__cell--${column.align}`,
555
+ column.kind === "selection" && "ds-table__cell--selection",
556
+ column.kind === "actions" && "ds-table__cell--actions",
557
+ );
558
+ }
559
+
560
+ function columnStyle<Row extends TableRowBase>(column: TableColumn<Row>): CSSProperties {
561
+ return {
562
+ minWidth: column.minWidth,
563
+ width: typeof column.width === "number" ? column.width : undefined,
564
+ };
565
+ }
566
+
567
+ type ResolvedDataTableState = DataTableState & {
568
+ tone: "danger" | "default" | "loading";
569
+ };
570
+
571
+ function DataTableActionButton({ action }: { action: DataTableAction }) {
572
+ return (
573
+ <ToolbarButton
574
+ aria-label={action.ariaLabel}
575
+ disabled={action.disabled}
576
+ onClick={action.onAction}
577
+ size="sm"
578
+ variant={action.variant ?? "soft"}
579
+ >
580
+ {action.label}
581
+ </ToolbarButton>
582
+ );
583
+ }
584
+
585
+ function DataTableStateView({ loading, state }: { loading: boolean; state: ResolvedDataTableState }) {
586
+ return (
587
+ <div className={`ds-data-table__state ds-data-table__state--${state.tone}`} role={state.tone === "danger" ? "alert" : "status"}>
588
+ <div className="ds-data-table__state-copy">
589
+ {loading && <Spinner className="ds-data-table__spinner" />}
590
+ {state.title && <strong>{state.title}</strong>}
591
+ {state.description && <p>{state.description}</p>}
592
+ </div>
593
+ {state.action && (
594
+ <div className="ds-data-table__state-action">
595
+ <DataTableActionButton action={state.action} />
596
+ </div>
597
+ )}
598
+ </div>
599
+ );
600
+ }
601
+
602
+ function getDataTableState({
603
+ emptyState,
604
+ error,
605
+ labels,
606
+ loading,
607
+ rowCount,
608
+ }: {
609
+ emptyState?: DataTableState;
610
+ error: boolean | string | DataTableState;
611
+ labels: DataTableLabels;
612
+ loading: boolean;
613
+ rowCount: number;
614
+ }): ResolvedDataTableState | null {
615
+ if (loading) {
616
+ return {
617
+ description: labels.loadingDescription,
618
+ title: labels.loadingTitle,
619
+ tone: "loading",
620
+ };
621
+ }
622
+
623
+ if (error) {
624
+ if (typeof error === "string") {
625
+ return {
626
+ description: error,
627
+ title: labels.errorTitle,
628
+ tone: "danger",
629
+ };
630
+ }
631
+
632
+ if (typeof error === "object") {
633
+ return {
634
+ action: error.action,
635
+ description: error.description ?? labels.errorDescription,
636
+ title: error.title ?? labels.errorTitle,
637
+ tone: "danger",
638
+ };
639
+ }
640
+
641
+ return {
642
+ description: labels.errorDescription,
643
+ title: labels.errorTitle,
644
+ tone: "danger",
645
+ };
646
+ }
647
+
648
+ if (rowCount === 0) {
649
+ return {
650
+ action: emptyState?.action,
651
+ description: emptyState?.description ?? labels.emptyDescription,
652
+ title: emptyState?.title ?? labels.emptyTitle,
653
+ tone: "default",
654
+ };
655
+ }
656
+
657
+ return null;
658
+ }
659
+
660
+ function getDataTableSearchText<Row extends TableRowBase>({
661
+ columns,
662
+ getRowSearchText,
663
+ row,
664
+ }: {
665
+ columns: TableColumn<Row>[];
666
+ getRowSearchText?: (row: Row) => string;
667
+ row: Row;
668
+ }) {
669
+ const values = [row.id, getRowSearchText?.(row)];
670
+
671
+ for (const column of columns) {
672
+ const columnValue = column.searchValue?.(row) ?? (column.item ? getTableItemText(column.item(row)) : undefined);
673
+ if (columnValue) values.push(columnValue);
674
+ }
675
+
676
+ return values.filter(Boolean).join(" ");
677
+ }
678
+
679
+ function sortDataTableRows<Row extends TableRowBase>(rows: Row[], columns: TableColumn<Row>[], sort: TableSortState) {
680
+ if (!sort) return rows;
681
+
682
+ const column = columns.find((item) => item.id === sort.columnId);
683
+ if (!column) return rows;
684
+
685
+ const direction = sort.direction === "asc" ? 1 : -1;
686
+
687
+ return [...rows].sort((left, right) => {
688
+ const result = compareTableSortValues(getColumnSortValue(column, left), getColumnSortValue(column, right));
689
+ return result * direction;
690
+ });
691
+ }
692
+
693
+ function getColumnSortValue<Row extends TableRowBase>(column: TableColumn<Row>, row: Row): TableSortValue {
694
+ if (column.sortValue) return column.sortValue(row);
695
+ if (column.item) return getTableItemSortValue(column.item(row));
696
+ return undefined;
697
+ }
698
+
699
+ function compareTableSortValues(left: TableSortValue, right: TableSortValue) {
700
+ if (left === right) return 0;
701
+ if (left === null || left === undefined) return 1;
702
+ if (right === null || right === undefined) return -1;
703
+
704
+ const normalizedLeft = normalizeTableSortValue(left);
705
+ const normalizedRight = normalizeTableSortValue(right);
706
+
707
+ if (typeof normalizedLeft === "number" && typeof normalizedRight === "number") {
708
+ return normalizedLeft - normalizedRight;
709
+ }
710
+
711
+ return String(normalizedLeft).localeCompare(String(normalizedRight), undefined, {
712
+ numeric: true,
713
+ sensitivity: "base",
714
+ });
715
+ }
716
+
717
+ function normalizeTableSortValue(value: Exclude<TableSortValue, null | undefined>) {
718
+ if (value instanceof Date) return value.getTime();
719
+ if (typeof value === "boolean") return value ? 1 : 0;
720
+ return value;
721
+ }
722
+
723
+ function getTableItemSortValue(item: TableItem): TableSortValue {
724
+ switch (item.type) {
725
+ case "avatarText":
726
+ return item.name;
727
+ case "badges":
728
+ return item.items.map((badge) => badge.label).join(" ");
729
+ case "brandLogoText":
730
+ return item.label;
731
+ case "file":
732
+ return item.label;
733
+ case "image":
734
+ return item.label;
735
+ case "payment":
736
+ return item.label ?? item.brand;
737
+ case "progress":
738
+ case "rating":
739
+ return item.value;
740
+ case "tags":
741
+ return item.items.join(" ");
742
+ case "text":
743
+ return item.value;
744
+ case "toggle":
745
+ return item.checked;
746
+ default:
747
+ return undefined;
748
+ }
749
+ }
750
+
751
+ function getTableItemText(item: TableItem) {
752
+ const value = getTableItemSortValue(item);
753
+ return value === null || value === undefined ? "" : String(value);
754
+ }
755
+
756
+ function getDataTablePaginationPages(currentPage: number, pageCount: number): PaginationPage[] {
757
+ if (pageCount <= 5) {
758
+ return Array.from({ length: pageCount }, (_, index) => index + 1);
759
+ }
760
+
761
+ const pages: PaginationPage[] = [1];
762
+ const start = Math.max(2, currentPage - 1);
763
+ const end = Math.min(pageCount - 1, currentPage + 1);
764
+
765
+ if (start > 2) pages.push("ellipsis");
766
+ for (let page = start; page <= end; page += 1) {
767
+ pages.push(page);
768
+ }
769
+ if (end < pageCount - 1) pages.push("ellipsis");
770
+ pages.push(pageCount);
771
+
772
+ return pages;
773
+ }
774
+
775
+ function getAriaSort(sort: TableSortState, columnId: string) {
776
+ if (sort?.columnId !== columnId) return "none";
777
+ return sort.direction === "asc" ? "ascending" : "descending";
778
+ }
779
+
780
+ function getNextSort(sort: TableSortState, columnId: string): TableSortState {
781
+ if (sort?.columnId !== columnId) {
782
+ return { columnId, direction: "asc" };
783
+ }
784
+
785
+ return {
786
+ columnId,
787
+ direction: sort.direction === "asc" ? "desc" : "asc",
788
+ };
789
+ }
790
+
791
+ function clampNumber(value: number, min: number, max: number) {
792
+ if (!Number.isFinite(value)) return min;
793
+ return Math.min(max, Math.max(min, Math.floor(value)));
794
+ }
795
+
796
+ function renderTableItem(item: TableItem, tableSize: TableSize) {
797
+ switch (item.type) {
798
+ case "checkbox":
799
+ return (
800
+ <SelectionCheckbox
801
+ checked={item.checked}
802
+ disabled={item.disabled ?? !item.onCheckedChange}
803
+ indeterminate={item.indeterminate}
804
+ label={item.ariaLabel ?? "Select row"}
805
+ onChange={item.onCheckedChange}
806
+ />
807
+ );
808
+ case "text":
809
+ return <span className={`ds-table-item-text ds-table-item-text--${item.tone ?? "default"} ds-table-item-text--${item.weight ?? "regular"}`}>{item.value}</span>;
810
+ case "avatarText":
811
+ return (
812
+ <span className="ds-table-avatar-text">
813
+ <Avatar name={item.name} size={tableSize === "lg" ? "md" : "sm"} src={item.src} />
814
+ <span>
815
+ <strong dir="auto">{item.name}</strong>
816
+ {item.caption && <em dir="auto">{item.caption}</em>}
817
+ </span>
818
+ </span>
819
+ );
820
+ case "rating":
821
+ return <Rating value={item.value} max={item.max ?? 5} />;
822
+ case "progress":
823
+ return <Progress value={item.value} label={item.label} tone={item.tone ?? "success"} />;
824
+ case "toggle":
825
+ return (
826
+ <Toggle
827
+ aria-label={item.ariaLabel ?? "Toggle row value"}
828
+ checked={item.checked}
829
+ disabled={item.disabled ?? !item.onCheckedChange}
830
+ onCheckedChange={item.onCheckedChange}
831
+ size="sm"
832
+ />
833
+ );
834
+ case "badges":
835
+ return <BadgeList items={item.items} maxVisible={item.maxVisible} />;
836
+ case "onlyAvatar":
837
+ return <Avatar name={item.alt} size={tableSize === "lg" ? "md" : "xs"} src={item.src} />;
838
+ case "avatarStack":
839
+ return (
840
+ <AvatarStack
841
+ addLabel={item.addLabel}
842
+ avatars={item.avatars}
843
+ canAdd={item.canAdd}
844
+ count={item.count}
845
+ onAdd={item.onAdd}
846
+ size={tableSize}
847
+ />
848
+ );
849
+ case "tags":
850
+ return <TagList items={item.items} maxVisible={item.maxVisible} />;
851
+ case "file":
852
+ return item.label ? <IconLabel icon={<FileIcon />} label={item.label} /> : null;
853
+ case "brandLogoText":
854
+ return <IconLabel icon={item.logoSrc ? <img alt="" src={item.logoSrc} /> : <BrandIcon />} label={item.label} />;
855
+ case "payment":
856
+ return <IconLabel icon={<PaymentIcon />} label={item.label ?? item.brand} />;
857
+ case "image":
858
+ return <IconLabel icon={<img alt="" src={item.src} />} label={item.label} />;
859
+ case "action":
860
+ return <IconButton icon={item.icon} label={item.label} onClick={item.onAction} />;
861
+ case "actionGroup":
862
+ return (
863
+ <span className="ds-table-action-group">
864
+ {item.actions.map((action) => (
865
+ <IconButton disabled={action.disabled} key={action.label} icon={action.icon} label={action.label} onClick={action.onAction} />
866
+ ))}
867
+ </span>
868
+ );
869
+ default:
870
+ return null;
871
+ }
872
+ }
873
+
874
+ function SelectionCheckbox({
875
+ checked,
876
+ disabled = false,
877
+ indeterminate = false,
878
+ label,
879
+ onChange,
880
+ }: {
881
+ checked: boolean;
882
+ disabled?: boolean;
883
+ indeterminate?: boolean;
884
+ label: string;
885
+ onChange?: (checked: boolean) => void;
886
+ }) {
887
+ return (
888
+ <Checkbox
889
+ aria-label={label}
890
+ checked={indeterminate ? "indeterminate" : checked}
891
+ disabled={disabled}
892
+ onCheckedChange={(nextChecked) => onChange?.(nextChecked === true)}
893
+ />
894
+ );
895
+ }
896
+
897
+ type TableAvatarSize = "xs" | "sm" | "md";
898
+
899
+ function Avatar({ name, size, src }: { name: string; size: TableAvatarSize; src?: string }) {
900
+ const initials = name
901
+ .split(" ")
902
+ .map((part) => part[0])
903
+ .slice(0, 2)
904
+ .join("")
905
+ .toUpperCase();
906
+
907
+ return (
908
+ <AvatarPrimitive className="ds-table-avatar" size={size}>
909
+ {src ? <AvatarImage alt="" src={src} /> : null}
910
+ <AvatarFallback size={size}>{initials}</AvatarFallback>
911
+ </AvatarPrimitive>
912
+ );
913
+ }
914
+
915
+ function AvatarStack({
916
+ addLabel = "Add person",
917
+ avatars,
918
+ canAdd,
919
+ count,
920
+ onAdd,
921
+ size,
922
+ }: {
923
+ addLabel?: string;
924
+ avatars: AvatarItem[];
925
+ canAdd?: boolean;
926
+ count?: number;
927
+ onAdd?: () => void;
928
+ size: TableSize;
929
+ }) {
930
+ return (
931
+ <span className={`ds-table-avatar-stack ds-table-avatar-stack--${size}`}>
932
+ {avatars.slice(0, 3).map((avatar) => (
933
+ <Avatar key={avatar.alt} name={avatar.alt} size={size === "lg" ? "md" : "xs"} src={avatar.src} />
934
+ ))}
935
+ {count ? <span className="ds-table-avatar ds-table-avatar--count">+{count}</span> : null}
936
+ {canAdd ? (
937
+ <Button aria-label={addLabel} disabled={!onAdd} onClick={onAdd} size="icon-xs" type="button" variant="ghost">
938
+ +
939
+ </Button>
940
+ ) : null}
941
+ </span>
942
+ );
943
+ }
944
+
945
+ function BadgeList({ items, maxVisible = items.length }: { items: BadgeItem[]; maxVisible?: number }) {
946
+ const visible = items.slice(0, maxVisible);
947
+ const overflow = items.length - visible.length;
948
+
949
+ return (
950
+ <span className="ds-table-badges">
951
+ {visible.map((item, index) => (
952
+ <Badge
953
+ badgeStyle="light"
954
+ color={badgeColor(item.tone)}
955
+ key={`${item.label}-${index}`}
956
+ size="xs"
957
+ type={item.dot ? "dot" : "default"}
958
+ >
959
+ {item.label}
960
+ </Badge>
961
+ ))}
962
+ {overflow > 0 && <Badge size="xs">+{overflow}</Badge>}
963
+ </span>
964
+ );
965
+ }
966
+
967
+ function TagList({ items, maxVisible = items.length }: { items: string[]; maxVisible?: number }) {
968
+ const visible = items.slice(0, maxVisible);
969
+ const overflow = items.length - visible.length;
970
+
971
+ return (
972
+ <span className="ds-table-tags">
973
+ {visible.map((item, index) => (
974
+ <Tag key={`${item}-${index}`} size="xs">{item}</Tag>
975
+ ))}
976
+ {overflow > 0 && <Tag size="xs">+{overflow}</Tag>}
977
+ </span>
978
+ );
979
+ }
980
+
981
+ function Progress({ label, tone, value }: { label?: string; tone: "success" | "brand"; value: number }) {
982
+ return (
983
+ <ProgressPrimitive
984
+ aria-label={label ?? "Progress"}
985
+ className={`ds-table-progress ds-table-progress--${tone}`}
986
+ indicator={label}
987
+ labelPosition="inline"
988
+ size="lg"
989
+ value={value}
990
+ />
991
+ );
992
+ }
993
+
994
+ function Rating({ max, value }: { max: number; value: number }) {
995
+ return (
996
+ <span
997
+ aria-label={`${value} out of ${max}`}
998
+ className="ds-table-rating"
999
+ role="img"
1000
+ >
1001
+ {Array.from({ length: max }, (_, index) => (
1002
+ <StarIcon
1003
+ aria-hidden="true"
1004
+ className={index < value ? "is-filled" : undefined}
1005
+ key={index}
1006
+ strokeWidth="var(--bh-icon-stroke-190)"
1007
+ />
1008
+ ))}
1009
+ </span>
1010
+ );
1011
+ }
1012
+
1013
+ function IconLabel({ icon, label }: { icon: ReactNode; label: string }) {
1014
+ return (
1015
+ <span className="ds-table-icon-label">
1016
+ <span aria-hidden="true">{icon}</span>
1017
+ <span>{label}</span>
1018
+ </span>
1019
+ );
1020
+ }
1021
+
1022
+ function IconButton({
1023
+ disabled,
1024
+ icon,
1025
+ label,
1026
+ onClick,
1027
+ }: {
1028
+ disabled?: boolean;
1029
+ icon: TableAction["icon"];
1030
+ label: string;
1031
+ onClick?: () => void;
1032
+ }) {
1033
+ return (
1034
+ <Button aria-label={label} className="ds-table-icon-button" disabled={disabled ?? !onClick} onClick={onClick} size="icon-xs" type="button" variant="ghost">
1035
+ {renderActionIcon(icon)}
1036
+ </Button>
1037
+ );
1038
+ }
1039
+
1040
+ function badgeColor(tone: BadgeItem["tone"]) {
1041
+ if (tone === "success") return "green" as const;
1042
+ if (tone === "warning") return "amber" as const;
1043
+ return tone ?? "neutral";
1044
+ }
1045
+
1046
+ function renderActionIcon(icon: TableAction["icon"]) {
1047
+ switch (icon) {
1048
+ case "view":
1049
+ return <ViewIcon />;
1050
+ case "edit":
1051
+ return <EditIcon />;
1052
+ case "delete":
1053
+ return <DeleteIcon />;
1054
+ case "more":
1055
+ default:
1056
+ return <MoreIcon />;
1057
+ }
1058
+ }
1059
+
1060
+ function SortIcon({ direction }: { direction?: TableSortDirection }) {
1061
+ return (
1062
+ <ArrowUpDownIcon aria-hidden="true" data-direction={direction ?? "none"} strokeWidth="var(--bh-icon-stroke-190)" />
1063
+ );
1064
+ }
1065
+
1066
+ function MoreIcon() {
1067
+ return <EllipsisIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-225)" />;
1068
+ }
1069
+
1070
+ function ViewIcon() {
1071
+ return <EyeIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1072
+ }
1073
+
1074
+ function EditIcon() {
1075
+ return <LucideEditIcon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1076
+ }
1077
+
1078
+ function DeleteIcon() {
1079
+ return <Trash2Icon aria-hidden="true" strokeWidth="var(--bh-icon-stroke-190)" />;
1080
+ }
1081
+
1082
+ function FileIcon() {
1083
+ return <LucideFileIcon strokeWidth="var(--bh-icon-stroke-190)" />;
1084
+ }
1085
+
1086
+ function BrandIcon() {
1087
+ return <span className="ds-table-brand-icon" />;
1088
+ }
1089
+
1090
+ function PaymentIcon() {
1091
+ return <span className="ds-table-payment-icon" />;
1092
+ }