@exxatdesignux/ui 0.2.18 → 0.3.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 (618) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/bin/sync-extras.mjs +116 -29
  3. package/consumer-extras/README.md +43 -4
  4. package/consumer-extras/cursor-rules/exxat-accessibility.mdc +39 -0
  5. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +26 -0
  6. package/consumer-extras/cursor-rules/exxat-breadcrumbs-no-back.mdc +21 -0
  7. package/consumer-extras/cursor-rules/exxat-card-vs-list-rows.mdc +21 -0
  8. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +44 -0
  9. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +32 -0
  10. package/consumer-extras/cursor-rules/exxat-command-menu.mdc +22 -0
  11. package/consumer-extras/cursor-rules/exxat-dashboard-view-charts.mdc +53 -0
  12. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +41 -0
  13. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +25 -0
  14. package/consumer-extras/cursor-rules/exxat-drawer-vs-dialog.mdc +22 -0
  15. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +56 -0
  16. package/consumer-extras/cursor-rules/exxat-fontawesome-icons.mdc +31 -0
  17. package/consumer-extras/cursor-rules/exxat-kbd-shortcuts.mdc +100 -0
  18. package/consumer-extras/cursor-rules/exxat-kpi-flat-band.mdc +28 -0
  19. package/consumer-extras/cursor-rules/exxat-kpi-max-four.mdc +21 -0
  20. package/consumer-extras/cursor-rules/exxat-kpi-trends.mdc +31 -0
  21. package/consumer-extras/cursor-rules/exxat-list-page-connected-views.mdc +24 -0
  22. package/consumer-extras/cursor-rules/exxat-list-page-view-shells.mdc +31 -0
  23. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +30 -0
  24. package/consumer-extras/cursor-rules/exxat-no-slds-leakage.mdc +78 -0
  25. package/consumer-extras/cursor-rules/exxat-no-toast.mdc +25 -0
  26. package/consumer-extras/cursor-rules/exxat-page-vs-drawer.mdc +23 -0
  27. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +47 -0
  28. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +52 -0
  29. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +28 -0
  30. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +34 -0
  31. package/consumer-extras/cursor-rules/exxat-table-properties-drawer.mdc +77 -0
  32. package/consumer-extras/cursor-rules/exxat-token-discipline.mdc +103 -0
  33. package/consumer-extras/cursor-skills/exxat-accessibility/SKILL.md +1 -1
  34. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  35. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  36. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +9 -9
  37. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +1 -1
  38. package/consumer-extras/handbook/HANDBOOK.md +185 -0
  39. package/consumer-extras/handbook/glossary.md +57 -0
  40. package/consumer-extras/handbook/reference-implementations.md +126 -0
  41. package/consumer-extras/handbook/voice-and-tone.md +262 -0
  42. package/consumer-extras/patterns/command-menu-pattern.md +1 -1
  43. package/consumer-extras/patterns/data-views-pattern.md +14 -14
  44. package/dist/components/data-table/filter-date-calendar.d.ts +10 -0
  45. package/dist/components/data-table/filter-date-calendar.js +280 -0
  46. package/dist/components/data-table/filter-date-calendar.js.map +1 -0
  47. package/dist/components/data-table/filter-text-value-input.d.ts +15 -0
  48. package/dist/components/data-table/filter-text-value-input.js +561 -0
  49. package/dist/components/data-table/filter-text-value-input.js.map +1 -0
  50. package/dist/components/data-table/index.d.ts +45 -0
  51. package/dist/components/data-table/index.js +3085 -0
  52. package/dist/components/data-table/index.js.map +1 -0
  53. package/dist/components/data-table/pagination.d.ts +28 -0
  54. package/dist/components/data-table/pagination.js +3264 -0
  55. package/dist/components/data-table/pagination.js.map +1 -0
  56. package/dist/components/data-table/types.d.ts +84 -0
  57. package/dist/components/data-table/types.js +3 -0
  58. package/dist/components/data-table/types.js.map +1 -0
  59. package/dist/components/data-table/use-table-state.d.ts +116 -0
  60. package/dist/components/data-table/use-table-state.js +670 -0
  61. package/dist/components/data-table/use-table-state.js.map +1 -0
  62. package/dist/components/data-views/board-card-primitives.d.ts +22 -0
  63. package/dist/components/data-views/board-card-primitives.js +84 -0
  64. package/dist/components/data-views/board-card-primitives.js.map +1 -0
  65. package/dist/components/data-views/data-row-list.d.ts +33 -0
  66. package/dist/components/data-views/data-row-list.js +106 -0
  67. package/dist/components/data-views/data-row-list.js.map +1 -0
  68. package/dist/components/data-views/finder-panel-view.d.ts +54 -0
  69. package/dist/components/data-views/finder-panel-view.js +388 -0
  70. package/dist/components/data-views/finder-panel-view.js.map +1 -0
  71. package/dist/components/data-views/folder-grid-view.d.ts +22 -0
  72. package/dist/components/data-views/folder-grid-view.js +58 -0
  73. package/dist/components/data-views/folder-grid-view.js.map +1 -0
  74. package/dist/components/data-views/hub-table.d.ts +167 -0
  75. package/dist/components/data-views/hub-table.js +5561 -0
  76. package/dist/components/data-views/hub-table.js.map +1 -0
  77. package/dist/components/data-views/index.d.ts +27 -0
  78. package/dist/components/data-views/index.js +6575 -0
  79. package/dist/components/data-views/index.js.map +1 -0
  80. package/dist/components/data-views/list-page-board-card.d.ts +72 -0
  81. package/dist/components/data-views/list-page-board-card.js +264 -0
  82. package/dist/components/data-views/list-page-board-card.js.map +1 -0
  83. package/dist/components/data-views/list-page-board-template.d.ts +24 -0
  84. package/dist/components/data-views/list-page-board-template.js +137 -0
  85. package/dist/components/data-views/list-page-board-template.js.map +1 -0
  86. package/dist/components/data-views/list-page-connected-view-body.d.ts +19 -0
  87. package/dist/components/data-views/list-page-connected-view-body.js +116 -0
  88. package/dist/components/data-views/list-page-connected-view-body.js.map +1 -0
  89. package/dist/components/data-views/list-page-split-details-placeholder.d.ts +14 -0
  90. package/dist/components/data-views/list-page-split-details-placeholder.js +38 -0
  91. package/dist/components/data-views/list-page-split-details-placeholder.js.map +1 -0
  92. package/dist/components/data-views/list-page-split-hub-chrome.d.ts +17 -0
  93. package/dist/components/data-views/list-page-split-hub-chrome.js +54 -0
  94. package/dist/components/data-views/list-page-split-hub-chrome.js.map +1 -0
  95. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +12 -0
  96. package/dist/components/data-views/list-page-split-hub-tokens.js +8 -0
  97. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -0
  98. package/dist/components/data-views/list-page-tree-column-header.d.ts +15 -0
  99. package/dist/components/data-views/list-page-tree-column-header.js +22 -0
  100. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -0
  101. package/dist/components/data-views/list-page-tree-panel-shell.d.ts +25 -0
  102. package/dist/components/data-views/list-page-tree-panel-shell.js +146 -0
  103. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -0
  104. package/dist/components/data-views/os-folder-glyph.d.ts +35 -0
  105. package/dist/components/data-views/os-folder-glyph.js +104 -0
  106. package/dist/components/data-views/os-folder-glyph.js.map +1 -0
  107. package/dist/components/data-views/outline-tree-menu.d.ts +36 -0
  108. package/dist/components/data-views/outline-tree-menu.js +131 -0
  109. package/dist/components/data-views/outline-tree-menu.js.map +1 -0
  110. package/dist/components/table-properties/column-row.d.ts +22 -0
  111. package/dist/components/table-properties/column-row.js +153 -0
  112. package/dist/components/table-properties/column-row.js.map +1 -0
  113. package/dist/components/table-properties/draggable-list.d.ts +24 -0
  114. package/dist/components/table-properties/draggable-list.js +53 -0
  115. package/dist/components/table-properties/draggable-list.js.map +1 -0
  116. package/dist/components/table-properties/drawer-button.d.ts +110 -0
  117. package/dist/components/table-properties/drawer-button.js +2748 -0
  118. package/dist/components/table-properties/drawer-button.js.map +1 -0
  119. package/dist/components/table-properties/drawer.d.ts +100 -0
  120. package/dist/components/table-properties/drawer.js +2595 -0
  121. package/dist/components/table-properties/drawer.js.map +1 -0
  122. package/dist/components/table-properties/filter-card.d.ts +24 -0
  123. package/dist/components/table-properties/filter-card.js +854 -0
  124. package/dist/components/table-properties/filter-card.js.map +1 -0
  125. package/dist/components/table-properties/index.d.ts +14 -0
  126. package/dist/components/table-properties/index.js +2768 -0
  127. package/dist/components/table-properties/index.js.map +1 -0
  128. package/dist/components/table-properties/sort-card.d.ts +20 -0
  129. package/dist/components/table-properties/sort-card.js +102 -0
  130. package/dist/components/table-properties/sort-card.js.map +1 -0
  131. package/dist/components/templates/dedicated-search-landing-template.d.ts +21 -0
  132. package/dist/components/templates/dedicated-search-landing-template.js +254 -0
  133. package/dist/components/templates/dedicated-search-landing-template.js.map +1 -0
  134. package/dist/components/templates/dedicated-search-results-template.d.ts +15 -0
  135. package/dist/components/templates/dedicated-search-results-template.js +16 -0
  136. package/dist/components/templates/dedicated-search-results-template.js.map +1 -0
  137. package/dist/components/templates/index.d.ts +9 -0
  138. package/dist/components/templates/index.js +2720 -0
  139. package/dist/components/templates/index.js.map +1 -0
  140. package/dist/components/templates/list-page.d.ts +83 -0
  141. package/dist/components/templates/list-page.js +2433 -0
  142. package/dist/components/templates/list-page.js.map +1 -0
  143. package/dist/components/templates/nested-secondary-panel-shell.d.ts +20 -0
  144. package/dist/components/templates/nested-secondary-panel-shell.js +54 -0
  145. package/dist/components/templates/nested-secondary-panel-shell.js.map +1 -0
  146. package/dist/components/ui/accordion.d.ts +10 -0
  147. package/dist/components/ui/accordion.js +74 -0
  148. package/dist/components/ui/accordion.js.map +1 -0
  149. package/dist/components/ui/alert-dialog.d.ts +37 -0
  150. package/dist/components/ui/alert-dialog.js +201 -0
  151. package/dist/components/ui/alert-dialog.js.map +1 -0
  152. package/dist/components/ui/avatar.d.ts +84 -0
  153. package/dist/components/ui/avatar.js +328 -0
  154. package/dist/components/ui/avatar.js.map +1 -0
  155. package/dist/components/ui/badge.d.ts +13 -0
  156. package/dist/components/ui/badge.js +49 -0
  157. package/dist/components/ui/badge.js.map +1 -0
  158. package/dist/components/ui/banner.d.ts +62 -0
  159. package/dist/components/ui/banner.js +364 -0
  160. package/dist/components/ui/banner.js.map +1 -0
  161. package/dist/components/ui/breadcrumb.d.ts +14 -0
  162. package/dist/components/ui/breadcrumb.js +114 -0
  163. package/dist/components/ui/breadcrumb.js.map +1 -0
  164. package/dist/components/ui/button.d.ts +16 -0
  165. package/dist/components/ui/button.js +59 -0
  166. package/dist/components/ui/button.js.map +1 -0
  167. package/dist/components/ui/calendar.d.ts +13 -0
  168. package/dist/components/ui/calendar.js +238 -0
  169. package/dist/components/ui/calendar.js.map +1 -0
  170. package/dist/components/ui/card.d.ts +14 -0
  171. package/dist/components/ui/card.js +102 -0
  172. package/dist/components/ui/card.js.map +1 -0
  173. package/dist/components/ui/chart.d.ts +58 -0
  174. package/dist/components/ui/chart.js +292 -0
  175. package/dist/components/ui/chart.js.map +1 -0
  176. package/dist/components/ui/checkbox.d.ts +23 -0
  177. package/dist/components/ui/checkbox.js +155 -0
  178. package/dist/components/ui/checkbox.js.map +1 -0
  179. package/dist/components/ui/coach-mark.d.ts +27 -0
  180. package/dist/components/ui/coach-mark.js +306 -0
  181. package/dist/components/ui/coach-mark.js.map +1 -0
  182. package/dist/components/ui/collapsible.d.ts +8 -0
  183. package/dist/components/ui/collapsible.js +35 -0
  184. package/dist/components/ui/collapsible.js.map +1 -0
  185. package/dist/components/ui/command.d.ts +36 -0
  186. package/dist/components/ui/command.js +274 -0
  187. package/dist/components/ui/command.js.map +1 -0
  188. package/dist/components/ui/context-menu.d.ts +32 -0
  189. package/dist/components/ui/context-menu.js +245 -0
  190. package/dist/components/ui/context-menu.js.map +1 -0
  191. package/dist/components/ui/date-picker-field.d.ts +38 -0
  192. package/dist/components/ui/date-picker-field.js +550 -0
  193. package/dist/components/ui/date-picker-field.js.map +1 -0
  194. package/dist/components/ui/dialog.d.ts +22 -0
  195. package/dist/components/ui/dialog.js +200 -0
  196. package/dist/components/ui/dialog.js.map +1 -0
  197. package/dist/components/ui/dot-pattern.d.ts +21 -0
  198. package/dist/components/ui/dot-pattern.js +139 -0
  199. package/dist/components/ui/dot-pattern.js.map +1 -0
  200. package/dist/components/ui/drag-handle-grip.d.ts +10 -0
  201. package/dist/components/ui/drag-handle-grip.js +15 -0
  202. package/dist/components/ui/drag-handle-grip.js.map +1 -0
  203. package/dist/components/ui/drawer.d.ts +16 -0
  204. package/dist/components/ui/drawer.js +125 -0
  205. package/dist/components/ui/drawer.js.map +1 -0
  206. package/dist/components/ui/dropdown-menu.d.ts +45 -0
  207. package/dist/components/ui/dropdown-menu.js +353 -0
  208. package/dist/components/ui/dropdown-menu.js.map +1 -0
  209. package/dist/components/ui/export-drawer.d.ts +11 -0
  210. package/dist/components/ui/export-drawer.js +1658 -0
  211. package/dist/components/ui/export-drawer.js.map +1 -0
  212. package/dist/components/ui/field.d.ts +30 -0
  213. package/dist/components/ui/field.js +249 -0
  214. package/dist/components/ui/field.js.map +1 -0
  215. package/dist/components/ui/form.d.ts +28 -0
  216. package/dist/components/ui/form.js +110 -0
  217. package/dist/components/ui/form.js.map +1 -0
  218. package/dist/components/ui/hover-card.d.ts +9 -0
  219. package/dist/components/ui/hover-card.js +43 -0
  220. package/dist/components/ui/hover-card.js.map +1 -0
  221. package/dist/components/ui/input-group.d.ts +20 -0
  222. package/dist/components/ui/input-group.js +219 -0
  223. package/dist/components/ui/input-group.js.map +1 -0
  224. package/dist/components/ui/input-mask.d.ts +39 -0
  225. package/dist/components/ui/input-mask.js +118 -0
  226. package/dist/components/ui/input-mask.js.map +1 -0
  227. package/dist/components/ui/input.d.ts +5 -0
  228. package/dist/components/ui/input.js +30 -0
  229. package/dist/components/ui/input.js.map +1 -0
  230. package/dist/components/ui/kbd.d.ts +20 -0
  231. package/dist/components/ui/kbd.js +45 -0
  232. package/dist/components/ui/kbd.js.map +1 -0
  233. package/dist/components/ui/key-metrics-context.d.ts +19 -0
  234. package/dist/components/ui/key-metrics-context.js +26 -0
  235. package/dist/components/ui/key-metrics-context.js.map +1 -0
  236. package/dist/components/ui/key-metrics.d.ts +131 -0
  237. package/dist/components/ui/key-metrics.js +1015 -0
  238. package/dist/components/ui/key-metrics.js.map +1 -0
  239. package/dist/components/ui/label.d.ts +6 -0
  240. package/dist/components/ui/label.js +28 -0
  241. package/dist/components/ui/label.js.map +1 -0
  242. package/dist/components/ui/list-page-view-frame.d.ts +22 -0
  243. package/dist/components/ui/list-page-view-frame.js +24 -0
  244. package/dist/components/ui/list-page-view-frame.js.map +1 -0
  245. package/dist/components/ui/page-header.d.ts +51 -0
  246. package/dist/components/ui/page-header.js +372 -0
  247. package/dist/components/ui/page-header.js.map +1 -0
  248. package/dist/components/ui/payment-card-fields.d.ts +10 -0
  249. package/dist/components/ui/payment-card-fields.js +80 -0
  250. package/dist/components/ui/payment-card-fields.js.map +1 -0
  251. package/dist/components/ui/popover.d.ts +10 -0
  252. package/dist/components/ui/popover.js +47 -0
  253. package/dist/components/ui/popover.js.map +1 -0
  254. package/dist/components/ui/radio-group.d.ts +29 -0
  255. package/dist/components/ui/radio-group.js +190 -0
  256. package/dist/components/ui/radio-group.js.map +1 -0
  257. package/dist/components/ui/resizable.d.ts +16 -0
  258. package/dist/components/ui/resizable.js +51 -0
  259. package/dist/components/ui/resizable.js.map +1 -0
  260. package/dist/components/ui/scroll-area.d.ts +8 -0
  261. package/dist/components/ui/scroll-area.js +66 -0
  262. package/dist/components/ui/scroll-area.js.map +1 -0
  263. package/dist/components/ui/select.d.ts +18 -0
  264. package/dist/components/ui/select.js +186 -0
  265. package/dist/components/ui/select.js.map +1 -0
  266. package/dist/components/ui/selection-tile-grid.d.ts +52 -0
  267. package/dist/components/ui/selection-tile-grid.js +347 -0
  268. package/dist/components/ui/selection-tile-grid.js.map +1 -0
  269. package/dist/components/ui/separator.d.ts +7 -0
  270. package/dist/components/ui/separator.js +33 -0
  271. package/dist/components/ui/separator.js.map +1 -0
  272. package/dist/components/ui/sheet.d.ts +18 -0
  273. package/dist/components/ui/sheet.js +181 -0
  274. package/dist/components/ui/sheet.js.map +1 -0
  275. package/dist/components/ui/sidebar.d.ts +94 -0
  276. package/dist/components/ui/sidebar.js +805 -0
  277. package/dist/components/ui/sidebar.js.map +1 -0
  278. package/dist/components/ui/skeleton.d.ts +5 -0
  279. package/dist/components/ui/skeleton.js +22 -0
  280. package/dist/components/ui/skeleton.js.map +1 -0
  281. package/dist/components/ui/slider.d.ts +7 -0
  282. package/dist/components/ui/slider.js +66 -0
  283. package/dist/components/ui/slider.js.map +1 -0
  284. package/dist/components/ui/sonner.d.ts +6 -0
  285. package/dist/components/ui/sonner.js +38 -0
  286. package/dist/components/ui/sonner.js.map +1 -0
  287. package/dist/components/ui/status-badge.d.ts +38 -0
  288. package/dist/components/ui/status-badge.js +77 -0
  289. package/dist/components/ui/status-badge.js.map +1 -0
  290. package/dist/components/ui/table.d.ts +13 -0
  291. package/dist/components/ui/table.js +115 -0
  292. package/dist/components/ui/table.js.map +1 -0
  293. package/dist/components/ui/tabs.d.ts +15 -0
  294. package/dist/components/ui/tabs.js +93 -0
  295. package/dist/components/ui/tabs.js.map +1 -0
  296. package/dist/components/ui/textarea.d.ts +6 -0
  297. package/dist/components/ui/textarea.js +25 -0
  298. package/dist/components/ui/textarea.js.map +1 -0
  299. package/dist/components/ui/tip.d.ts +12 -0
  300. package/dist/components/ui/tip.js +61 -0
  301. package/dist/components/ui/tip.js.map +1 -0
  302. package/dist/components/ui/toggle-group.d.ts +14 -0
  303. package/dist/components/ui/toggle-group.js +104 -0
  304. package/dist/components/ui/toggle-group.js.map +1 -0
  305. package/dist/components/ui/toggle-switch.d.ts +10 -0
  306. package/dist/components/ui/toggle-switch.js +33 -0
  307. package/dist/components/ui/toggle-switch.js.map +1 -0
  308. package/dist/components/ui/toggle.d.ts +13 -0
  309. package/dist/components/ui/toggle.js +51 -0
  310. package/dist/components/ui/toggle.js.map +1 -0
  311. package/dist/components/ui/tooltip.d.ts +10 -0
  312. package/dist/components/ui/tooltip.js +68 -0
  313. package/dist/components/ui/tooltip.js.map +1 -0
  314. package/dist/components/ui/view-segmented-control.d.ts +31 -0
  315. package/dist/components/ui/view-segmented-control.js +167 -0
  316. package/dist/components/ui/view-segmented-control.js.map +1 -0
  317. package/dist/data-list-view-registry-CyBoBML4.d.ts +73 -0
  318. package/dist/hooks/use-app-theme.d.ts +24 -0
  319. package/dist/hooks/use-app-theme.js +286 -0
  320. package/dist/hooks/use-app-theme.js.map +1 -0
  321. package/dist/hooks/use-coach-mark.d.ts +86 -0
  322. package/dist/hooks/use-coach-mark.js +218 -0
  323. package/dist/hooks/use-coach-mark.js.map +1 -0
  324. package/dist/hooks/use-mobile.d.ts +3 -0
  325. package/dist/hooks/use-mobile.js +29 -0
  326. package/dist/hooks/use-mobile.js.map +1 -0
  327. package/dist/hooks/use-mod-key-label.d.ts +6 -0
  328. package/dist/hooks/use-mod-key-label.js +25 -0
  329. package/dist/hooks/use-mod-key-label.js.map +1 -0
  330. package/dist/index.d.ts +120 -0
  331. package/dist/index.js +13324 -0
  332. package/dist/index.js.map +1 -0
  333. package/dist/lib/compose-refs.d.ts +6 -0
  334. package/dist/lib/compose-refs.js +17 -0
  335. package/dist/lib/compose-refs.js.map +1 -0
  336. package/dist/lib/conditional-rule-match.d.ts +30 -0
  337. package/dist/lib/conditional-rule-match.js +66 -0
  338. package/dist/lib/conditional-rule-match.js.map +1 -0
  339. package/dist/lib/data-list-display-options.d.ts +26 -0
  340. package/dist/lib/data-list-display-options.js +14 -0
  341. package/dist/lib/data-list-display-options.js.map +1 -0
  342. package/dist/lib/data-list-view-registry.d.ts +2 -0
  343. package/dist/lib/data-list-view-registry.js +102 -0
  344. package/dist/lib/data-list-view-registry.js.map +1 -0
  345. package/dist/lib/data-list-view-surface.d.ts +2 -0
  346. package/dist/lib/data-list-view-surface.js +80 -0
  347. package/dist/lib/data-list-view-surface.js.map +1 -0
  348. package/dist/lib/data-list-view.d.ts +21 -0
  349. package/dist/lib/data-list-view.js +25 -0
  350. package/dist/lib/data-list-view.js.map +1 -0
  351. package/dist/lib/date-filter.d.ts +22 -0
  352. package/dist/lib/date-filter.js +61 -0
  353. package/dist/lib/date-filter.js.map +1 -0
  354. package/dist/lib/dev-log.d.ts +8 -0
  355. package/dist/lib/dev-log.js +10 -0
  356. package/dist/lib/dev-log.js.map +1 -0
  357. package/dist/lib/dropdown-menu-surface.d.ts +14 -0
  358. package/dist/lib/dropdown-menu-surface.js +6 -0
  359. package/dist/lib/dropdown-menu-surface.js.map +1 -0
  360. package/dist/lib/editable-target.d.ts +12 -0
  361. package/dist/lib/editable-target.js +12 -0
  362. package/dist/lib/editable-target.js.map +1 -0
  363. package/dist/lib/list-page-table-properties.d.ts +35 -0
  364. package/dist/lib/list-page-table-properties.js +81 -0
  365. package/dist/lib/list-page-table-properties.js.map +1 -0
  366. package/dist/lib/raf-throttle.d.ts +23 -0
  367. package/dist/lib/raf-throttle.js +27 -0
  368. package/dist/lib/raf-throttle.js.map +1 -0
  369. package/dist/lib/row-height.d.ts +16 -0
  370. package/dist/lib/row-height.js +10 -0
  371. package/dist/lib/row-height.js.map +1 -0
  372. package/dist/lib/table-properties-types.d.ts +83 -0
  373. package/dist/lib/table-properties-types.js +19 -0
  374. package/dist/lib/table-properties-types.js.map +1 -0
  375. package/dist/lib/utils.d.ts +5 -0
  376. package/dist/lib/utils.js +11 -0
  377. package/dist/lib/utils.js.map +1 -0
  378. package/package.json +83 -18
  379. package/src/components/data-table/filter-date-calendar.tsx +38 -0
  380. package/src/components/data-table/filter-text-value-input.tsx +77 -0
  381. package/src/components/data-table/index.tsx +1678 -0
  382. package/src/components/data-table/pagination.tsx +255 -0
  383. package/src/components/data-table/types.ts +96 -0
  384. package/src/components/data-table/use-table-state.ts +767 -0
  385. package/src/components/data-views/board-card-primitives.tsx +93 -0
  386. package/src/components/data-views/data-row-list.tsx +183 -0
  387. package/src/components/data-views/finder-panel-view.tsx +405 -0
  388. package/src/components/data-views/folder-grid-view.tsx +86 -0
  389. package/src/components/data-views/hub-table.tsx +498 -0
  390. package/src/components/data-views/index.ts +28 -0
  391. package/src/components/data-views/list-page-board-card.tsx +192 -0
  392. package/src/components/data-views/list-page-board-template.tsx +122 -0
  393. package/src/components/data-views/list-page-connected-view-body.tsx +66 -0
  394. package/src/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  395. package/src/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  396. package/src/components/data-views/list-page-split-hub-tokens.ts +16 -0
  397. package/src/components/data-views/list-page-tree-column-header.tsx +31 -0
  398. package/src/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  399. package/src/components/data-views/os-folder-glyph.tsx +141 -0
  400. package/src/components/data-views/outline-tree-menu.tsx +157 -0
  401. package/src/components/table-properties/column-row.tsx +90 -0
  402. package/src/components/table-properties/draggable-list.ts +54 -0
  403. package/src/components/table-properties/drawer-button.tsx +300 -0
  404. package/src/components/table-properties/drawer.tsx +1148 -0
  405. package/src/components/table-properties/filter-card.tsx +251 -0
  406. package/src/components/table-properties/index.ts +36 -0
  407. package/src/components/table-properties/sort-card.tsx +63 -0
  408. package/src/components/templates/dedicated-search-landing-template.tsx +124 -0
  409. package/src/components/templates/dedicated-search-results-template.tsx +19 -0
  410. package/src/components/templates/index.ts +33 -0
  411. package/src/components/templates/list-page.tsx +602 -0
  412. package/src/components/templates/nested-secondary-panel-shell.tsx +70 -0
  413. package/src/components/ui/accordion.tsx +92 -0
  414. package/src/components/ui/alert-dialog.tsx +221 -0
  415. package/src/components/ui/avatar.tsx +13 -2
  416. package/src/components/ui/banner.tsx +2 -2
  417. package/src/components/ui/calendar.tsx +1 -1
  418. package/src/components/ui/coach-mark.tsx +1 -1
  419. package/src/components/ui/context-menu.tsx +291 -0
  420. package/src/components/ui/date-picker-field.tsx +2 -2
  421. package/src/components/ui/dot-pattern.tsx +183 -0
  422. package/src/components/ui/export-drawer.tsx +375 -0
  423. package/src/components/ui/hover-card.tsx +66 -0
  424. package/src/components/ui/key-metrics-context.tsx +78 -0
  425. package/src/components/ui/key-metrics.tsx +1133 -0
  426. package/src/components/ui/list-page-view-frame.tsx +64 -0
  427. package/src/components/ui/page-header.tsx +244 -0
  428. package/src/components/ui/payment-card-fields.tsx +2 -2
  429. package/src/components/ui/resizable.tsx +68 -0
  430. package/src/components/ui/scroll-area.tsx +72 -0
  431. package/src/components/ui/selection-tile-grid.tsx +9 -2
  432. package/src/components/ui/sidebar.tsx +84 -12
  433. package/src/components/ui/slider.tsx +83 -0
  434. package/src/globals.css +494 -151
  435. package/src/globals.d.ts +20 -0
  436. package/src/index.ts +68 -1
  437. package/src/lib/conditional-rule-match.ts +119 -0
  438. package/src/lib/data-list-display-options.ts +35 -0
  439. package/src/lib/data-list-view-registry.ts +104 -0
  440. package/src/lib/data-list-view-surface.ts +83 -0
  441. package/src/lib/data-list-view.ts +47 -0
  442. package/src/lib/dev-log.ts +10 -0
  443. package/src/lib/editable-target.ts +20 -0
  444. package/src/lib/list-page-table-properties.ts +48 -0
  445. package/src/lib/raf-throttle.ts +45 -0
  446. package/src/lib/row-height.ts +19 -0
  447. package/src/lib/table-properties-types.ts +98 -0
  448. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  449. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +3 -3
  450. package/template/.cursor/rules/exxat-data-tables.mdc +1 -1
  451. package/template/.cursor/rules/exxat-ds-agents.mdc +2 -2
  452. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +2 -2
  453. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  454. package/template/AGENTS.md +84 -20
  455. package/template/app/(app)/examples/page.tsx +0 -1
  456. package/template/app/(app)/layout.tsx +17 -4
  457. package/template/app/(app)/question-bank/layout.tsx +1 -1
  458. package/template/app/(app)/question-bank/new/page.tsx +11 -24
  459. package/template/app/globals.css +13 -1972
  460. package/template/components/ask-leo-sidebar.tsx +5 -1
  461. package/template/components/brand-color-picker.tsx +2 -2
  462. package/template/components/charts-overview.tsx +1 -1
  463. package/template/components/compliance-table.tsx +240 -384
  464. package/template/components/dashboard-report-charts.tsx +1 -1
  465. package/template/components/dashboard-tabs.tsx +1 -1
  466. package/template/components/data-table/filter-date-calendar.tsx +1 -38
  467. package/template/components/data-table/filter-text-value-input.tsx +1 -77
  468. package/template/components/data-table/index.tsx +1 -1634
  469. package/template/components/data-table/pagination.tsx +1 -255
  470. package/template/components/data-table/types.ts +1 -94
  471. package/template/components/data-table/use-table-state.test.ts +420 -0
  472. package/template/components/data-table/use-table-state.ts +1 -758
  473. package/template/components/data-view-dashboard-charts-compliance.tsx +2 -2
  474. package/template/components/data-view-dashboard-charts-team.tsx +2 -2
  475. package/template/components/data-view-dashboard-charts.tsx +2 -2
  476. package/template/components/data-views/board-card-primitives.tsx +1 -93
  477. package/template/components/data-views/data-row-list.tsx +1 -183
  478. package/template/components/data-views/finder-panel-view.tsx +1 -405
  479. package/template/components/data-views/folder-grid-view.tsx +1 -86
  480. package/template/components/data-views/hub-table.tsx +1 -0
  481. package/template/components/data-views/index.ts +42 -1
  482. package/template/components/data-views/list-page-board-card.tsx +1 -192
  483. package/template/components/data-views/list-page-board-template.tsx +1 -122
  484. package/template/components/data-views/list-page-connected-view-body.tsx +1 -0
  485. package/template/components/data-views/list-page-split-details-placeholder.tsx +1 -39
  486. package/template/components/data-views/list-page-split-hub-chrome.tsx +1 -60
  487. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -16
  488. package/template/components/data-views/list-page-tree-column-header.tsx +1 -31
  489. package/template/components/data-views/list-page-tree-panel-shell.tsx +1 -91
  490. package/template/components/data-views/list-page-view-frame.tsx +5 -53
  491. package/template/components/data-views/os-folder-glyph.tsx +1 -129
  492. package/template/components/data-views/outline-tree-menu.tsx +1 -157
  493. package/template/components/export-drawer.test.tsx +71 -0
  494. package/template/components/export-drawer.tsx +1 -375
  495. package/template/components/exxat-product-logo.tsx +5 -5
  496. package/template/components/hub-tree-panel-view.tsx +2 -2
  497. package/template/components/invite-collaborators-drawer.tsx +3 -3
  498. package/template/components/key-metrics-ask-leo-bridge.tsx +40 -0
  499. package/template/components/key-metrics.tsx +1 -1063
  500. package/template/components/leo-insight-indicator.tsx +2 -2
  501. package/template/components/new-placement-back-btn.tsx +1 -1
  502. package/template/components/new-placement-form.tsx +63 -189
  503. package/template/components/new-question-composer.tsx +432 -402
  504. package/template/components/onboarding/index.ts +9 -0
  505. package/template/components/onboarding/onboarding-01.tsx +1 -1
  506. package/template/components/onboarding/onboarding-02.tsx +1 -1
  507. package/template/components/onboarding/onboarding-03.tsx +1 -1
  508. package/template/components/onboarding/onboarding-04.tsx +1 -1
  509. package/template/components/page-header.tsx +8 -226
  510. package/template/components/placement-board-card.tsx +71 -83
  511. package/template/components/placements-board-view.tsx +3 -10
  512. package/template/components/placements-client.tsx +10 -42
  513. package/template/components/placements-list-view.tsx +22 -69
  514. package/template/components/placements-table-columns.tsx +8 -438
  515. package/template/components/placements-table.tsx +588 -1296
  516. package/template/components/product-switcher.tsx +1 -1
  517. package/template/components/product-wordmark.tsx +2 -1
  518. package/template/components/question-bank-client.tsx +4 -1
  519. package/template/components/question-bank-hub-client.tsx +1 -1
  520. package/template/components/question-bank-new-folder-sheet.tsx +2 -2
  521. package/template/components/question-bank-secondary-nav.tsx +3 -3
  522. package/template/components/question-bank-table.tsx +294 -526
  523. package/template/components/rotations-empty-state.tsx +1 -1
  524. package/template/components/rotations-panel-activator.tsx +1 -1
  525. package/template/components/settings-appearance-card.tsx +1 -1
  526. package/template/components/{app-sidebar-dynamic.tsx → sidebar/app-sidebar-dynamic.tsx} +1 -1
  527. package/template/components/{app-sidebar.tsx → sidebar/app-sidebar.tsx} +4 -4
  528. package/template/components/sidebar/index.ts +16 -0
  529. package/template/components/{secondary-nav.tsx → sidebar/secondary-nav.tsx} +2 -2
  530. package/template/components/{secondary-panel.tsx → sidebar/secondary-panel.tsx} +6 -3
  531. package/template/components/{sidebar-auto-collapse.tsx → sidebar/sidebar-auto-collapse.tsx} +6 -2
  532. package/template/components/{sidebar-shell.tsx → sidebar/sidebar-shell.tsx} +1 -1
  533. package/template/components/site-header.tsx +1 -1
  534. package/template/components/{sites-all-client.tsx → sites-client.tsx} +1 -1
  535. package/template/components/sites-table.tsx +124 -257
  536. package/template/components/table-properties/column-row.tsx +1 -90
  537. package/template/components/table-properties/draggable-list.ts +1 -49
  538. package/template/components/table-properties/drawer-button.tsx +1 -249
  539. package/template/components/table-properties/drawer.tsx +1 -1105
  540. package/template/components/table-properties/filter-card.tsx +1 -251
  541. package/template/components/table-properties/sort-card.tsx +1 -59
  542. package/template/components/table-properties/types.ts +28 -71
  543. package/template/components/team-table.tsx +242 -382
  544. package/template/components/templates/dedicated-search-landing-template.tsx +1 -124
  545. package/template/components/templates/dedicated-search-results-template.tsx +1 -19
  546. package/template/components/templates/list-page.tsx +1 -584
  547. package/template/components/templates/nested-secondary-panel-shell.tsx +1 -62
  548. package/template/components/templates/new-focus-template.tsx +659 -0
  549. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  550. package/template/components/ui/accordion.tsx +1 -0
  551. package/template/components/ui/alert-dialog.tsx +1 -0
  552. package/template/components/ui/context-menu.tsx +1 -0
  553. package/template/components/ui/dot-pattern.tsx +1 -183
  554. package/template/components/ui/hover-card.tsx +1 -0
  555. package/template/components/ui/resizable.tsx +1 -68
  556. package/template/components/ui/scroll-area.tsx +1 -0
  557. package/template/components/ui/slider.tsx +1 -0
  558. package/template/docs/blueprints/README.md +86 -0
  559. package/template/docs/blueprints/_template.md +91 -0
  560. package/template/docs/blueprints/board-card.md +123 -0
  561. package/template/docs/blueprints/data-table.md +139 -0
  562. package/template/docs/blueprints/key-metrics.md +128 -0
  563. package/template/docs/blueprints/list-page-template.md +123 -0
  564. package/template/docs/blueprints/page-header.md +130 -0
  565. package/template/docs/command-menu-pattern.md +1 -1
  566. package/template/docs/component-selection-guide.md +224 -0
  567. package/template/docs/components-audit-2026-05.md +158 -0
  568. package/template/docs/data-views-pattern.md +14 -14
  569. package/template/docs/migrations/0001-brand-deep-alias-stabilization.md +95 -0
  570. package/template/docs/migrations/0002-exxat-token-namespace.md +154 -0
  571. package/template/docs/migrations/0003-globals-css-canonical.md +110 -0
  572. package/template/docs/migrations/README.md +100 -0
  573. package/template/docs/migrations/_template.md +64 -0
  574. package/template/docs/token-taxonomy.md +416 -0
  575. package/template/eslint.config.mjs +27 -0
  576. package/template/hooks/use-secondary-panel-hub-nav.ts +1 -1
  577. package/template/lib/command-menu-config.ts +0 -1
  578. package/template/lib/compliance-supported-views.ts +10 -0
  579. package/template/lib/conditional-rule-match.ts +6 -97
  580. package/template/lib/data-list-display-options.ts +1 -35
  581. package/template/lib/data-list-view-registry.ts +1 -0
  582. package/template/lib/data-list-view-surface.ts +1 -69
  583. package/template/lib/data-list-view.ts +1 -38
  584. package/template/lib/dev-log.ts +1 -8
  585. package/template/lib/editable-target.ts +1 -10
  586. package/template/lib/hub-connected-view-renderers.ts +58 -0
  587. package/template/lib/list-hub-supported-views.ts +10 -0
  588. package/template/lib/list-page-table-properties.ts +1 -52
  589. package/template/lib/mock/navigation.tsx +0 -8
  590. package/template/lib/mock/placements.ts +0 -7
  591. package/template/lib/placement-board-card-layout.ts +41 -41
  592. package/template/lib/placements-supported-views.ts +12 -0
  593. package/template/lib/question-bank-supported-views.ts +12 -0
  594. package/template/lib/raf-throttle.ts +1 -45
  595. package/template/lib/row-height.ts +4 -10
  596. package/template/lib/sidebar-state-cookie.ts +11 -2
  597. package/template/lib/sites-supported-views.ts +10 -0
  598. package/template/lib/team-supported-views.ts +10 -0
  599. package/template/package.json +1 -0
  600. package/template/tests/setup.ts +25 -0
  601. package/src/theme.css +0 -1132
  602. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  603. package/template/app/(app)/data-list/new/page.tsx +0 -34
  604. package/template/app/(app)/data-list/page.tsx +0 -10
  605. package/template/components/compliance-list-view.tsx +0 -54
  606. package/template/components/dashboard-onboarding-gallery.tsx +0 -13
  607. package/template/components/dashboard-onboarding.tsx +0 -21
  608. package/template/components/question-bank-list-view.tsx +0 -53
  609. package/template/components/section-cards.tsx +0 -106
  610. package/template/components/sites-list-view.tsx +0 -42
  611. package/template/components/team-list-view.tsx +0 -59
  612. package/template/lib/placement-lifecycle.ts +0 -5
  613. /package/template/components/{getting-started.tsx → onboarding/getting-started.tsx} +0 -0
  614. /package/template/components/{nav-documents.tsx → sidebar/nav-documents.tsx} +0 -0
  615. /package/template/components/{nav-main.tsx → sidebar/nav-main.tsx} +0 -0
  616. /package/template/components/{nav-secondary.tsx → sidebar/nav-secondary.tsx} +0 -0
  617. /package/template/components/{nav-user.tsx → sidebar/nav-user.tsx} +0 -0
  618. /package/template/components/{sidebar-auto-open.tsx → sidebar/sidebar-auto-open.tsx} +0 -0
@@ -0,0 +1,1678 @@
1
+ "use client"
2
+
3
+ /**
4
+ * DataTable<TData> — generic reusable table (no pagination)
5
+ *
6
+ * Column features:
7
+ * • Resizable — drag trailing-edge handle on any non-locked column
8
+ * • Drag-to-reorder — drag header cell for free (unpinned) columns
9
+ * • Pin Left / Pin Right / Unpin — per-column context menu
10
+ * • Sort Asc / Desc — per-column context menu (sortable columns)
11
+ * • Wrap Text / Unwrap — per-column context menu
12
+ * • Per-column quick search
13
+ * • Row selection (checkboxes + floating bulk action bar)
14
+ * • Group by (collapsible group rows)
15
+ * • Hidden columns
16
+ *
17
+ * WCAG 2.1 AA:
18
+ * ✓ aria-sort on sortable <th>
19
+ * ✓ aria-label on every icon-only button
20
+ * ✓ Select / Actions columns: sr-only header text + resolved labels for controls
21
+ * ✓ Row checkboxes: visible on row focus-within, stop row click propagation (default control size; extended hit slop on Checkbox)
22
+ * ✓ Bulk-action bar: role="status" aria-live="polite"
23
+ * ✓ Resize handles: role="separator" aria-label
24
+ */
25
+
26
+ import * as React from "react"
27
+ import { useTheme } from "next-themes"
28
+ import { createPortal } from "react-dom"
29
+ import { cn } from "../../lib/utils"
30
+ import { rafThrottle } from "../../lib/raf-throttle"
31
+ import { Button } from "../ui/button"
32
+ import { Input } from "../ui/input"
33
+ import { Kbd, KbdGroup } from "../ui/kbd"
34
+ import { Tip } from "../ui/tip"
35
+ import { useModKeyLabel } from "../../hooks/use-mod-key-label"
36
+ import { isEditableTarget } from "../../lib/editable-target"
37
+ import { Checkbox } from "../ui/checkbox"
38
+ import {
39
+ DropdownMenu,
40
+ DropdownMenuContent,
41
+ DropdownMenuItem,
42
+ DropdownMenuLabel,
43
+ DropdownMenuSeparator,
44
+ DropdownMenuTrigger,
45
+ } from "../ui/dropdown-menu"
46
+ import {
47
+ Popover,
48
+ PopoverAnchor,
49
+ PopoverContent,
50
+ PopoverTrigger,
51
+ } from "../ui/popover"
52
+ import {
53
+ Tooltip,
54
+ TooltipContent,
55
+ TooltipProvider,
56
+ TooltipTrigger,
57
+ } from "../ui/tooltip"
58
+ import { OPERATOR_LABELS } from "../../lib/table-properties-types"
59
+ import type { ActiveFilter } from "../../lib/table-properties-types"
60
+ import { getConditionalCellBackground } from "../../lib/conditional-rule-match"
61
+ import { formatYmdForDisplay } from "../../lib/date-filter"
62
+ import { FilterDateCalendar } from "./filter-date-calendar"
63
+ import { FilterTextValueInput } from "./filter-text-value-input"
64
+ import type { DataTableProps, ColumnDef, SortDir } from "./types"
65
+ import { useTableState } from "./use-table-state"
66
+
67
+ /** When `ColumnDef.label` is empty, use a standard name for select/actions columns. */
68
+ function defaultColumnHeaderLabel(key: string): string | undefined {
69
+ switch (key) {
70
+ case "select":
71
+ return "Select"
72
+ case "actions":
73
+ return "Actions"
74
+ default:
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ function resolvedColumnLabel<TData>(col: ColumnDef<TData>): string {
80
+ const t = col.label?.trim()
81
+ if (t) return t
82
+ return defaultColumnHeaderLabel(col.key) ?? col.key
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Internal sub-components
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ const SortChevron = React.memo(function SortChevron({ dir }: { dir: SortDir }) {
90
+ return (
91
+ <i className={`fa-solid fa-arrow-${dir === "asc" ? "up" : "down"} ms-1 text-xs`} aria-hidden="true" />
92
+ )
93
+ })
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // FilterPill — active filter pill with inline editor popover
97
+ // (driven by ColumnDef.filter config rather than FILTER_FIELDS)
98
+ // ─────────────────────────────────────────────────────────────────────────────
99
+
100
+ interface FilterPillProps<TData> {
101
+ filter: ActiveFilter
102
+ columns: ColumnDef<TData>[]
103
+ defaultOpen?: boolean
104
+ onUpdate: (id: string, patch: Partial<ActiveFilter>) => void
105
+ onRemove: (id: string) => void
106
+ /** Optional custom cell renderer for filter option values */
107
+ renderOptionValue?: (fieldKey: string, value: string) => React.ReactNode
108
+ }
109
+
110
+ function FilterPillBase<TData>({
111
+ filter,
112
+ columns,
113
+ defaultOpen = false,
114
+ onUpdate,
115
+ onRemove,
116
+ renderOptionValue,
117
+ }: FilterPillProps<TData>) {
118
+ const [open, setOpen] = React.useState(false)
119
+ const [optSearch, setOptSearch] = React.useState("")
120
+ const justAutoOpenedRef = React.useRef(false)
121
+
122
+ React.useEffect(() => {
123
+ if (defaultOpen) {
124
+ justAutoOpenedRef.current = true
125
+ const t = setTimeout(() => {
126
+ setOpen(true)
127
+ setTimeout(() => { justAutoOpenedRef.current = false }, 400)
128
+ }, 0)
129
+ return () => clearTimeout(t)
130
+ }
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [])
133
+
134
+ const col = columns.find(c => c.key === filter.fieldKey)
135
+ const filterDef = col?.filter
136
+
137
+ React.useEffect(() => {
138
+ if (!filterDef) return
139
+ if (filterDef.type !== "select" && filterDef.type !== "date") return
140
+ if (filter.operator !== "is" && filter.operator !== "is_not") {
141
+ onUpdate(filter.id, { operator: "is" })
142
+ }
143
+ }, [filter.id, filterDef, filter.operator, onUpdate])
144
+
145
+ if (!filterDef) return null
146
+
147
+ const options = filterDef.options ?? []
148
+ const showSearch = options.length > 8
149
+ const filteredOpts = optSearch
150
+ ? options.filter(o => o.label.toLowerCase().includes(optSearch.toLowerCase()))
151
+ : options
152
+
153
+ const operators = filterDef.operators ?? (
154
+ filterDef.type === "select" || filterDef.type === "date"
155
+ ? (["is", "is_not"] as const)
156
+ : (["contains", "not_contains"] as const)
157
+ )
158
+
159
+ const valueLabel = (() => {
160
+ if (filterDef.type === "select") {
161
+ if (filter.values.length === 0) return "…"
162
+ if (filter.values.length === 1) {
163
+ return options.find(o => o.value === filter.values[0])?.label ?? filter.values[0]
164
+ }
165
+ return `${filter.values.length} selected`
166
+ }
167
+ if (filterDef.type === "date") {
168
+ const ymd = filter.values[0]
169
+ return ymd ? formatYmdForDisplay(ymd) : "…"
170
+ }
171
+ return filter.values[0] || "…"
172
+ })()
173
+
174
+ function toggleValue(val: string) {
175
+ const next = filter.values.includes(val)
176
+ ? filter.values.filter(v => v !== val)
177
+ : [...filter.values, val]
178
+ onUpdate(filter.id, { values: next })
179
+ }
180
+
181
+ function cycleOperator() {
182
+ const idx = operators.indexOf(filter.operator as typeof operators[number])
183
+ const i = idx === -1 ? 0 : idx
184
+ onUpdate(filter.id, { operator: operators[(i + 1) % operators.length] })
185
+ }
186
+
187
+ const isActive =
188
+ filterDef.type === "date"
189
+ ? Boolean(filter.values[0])
190
+ : filter.values.length > 0
191
+ const hasSelection = filter.values.length > 0
192
+ const iconClass = filterDef.icon ? `fa-light ${filterDef.icon}` : "fa-light fa-filter"
193
+
194
+ return (
195
+ <Popover open={open} onOpenChange={setOpen}>
196
+ <PopoverAnchor asChild>
197
+ <div
198
+ className={cn(
199
+ "inline-flex cursor-pointer items-center rounded border text-xs transition-colors",
200
+ isActive ? "border-brand/45 bg-brand/10" : "border-input bg-background"
201
+ )}
202
+ >
203
+ <PopoverTrigger asChild>
204
+ <button
205
+ type="button"
206
+ className={cn(
207
+ "inline-flex cursor-pointer items-center gap-1 h-6 ps-2 pe-1.5 rounded-s transition-colors",
208
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
209
+ isActive ? "hover:bg-brand/15" : "hover:bg-interactive-hover",
210
+ )}
211
+ >
212
+ <i
213
+ className={cn(iconClass, "text-xs", isActive ? "text-brand" : "text-muted-foreground")}
214
+ aria-hidden="true"
215
+ />
216
+ <span className="text-foreground">{col.label}</span>
217
+ {isActive && <span className="text-foreground font-medium">{valueLabel}</span>}
218
+ </button>
219
+ </PopoverTrigger>
220
+ <button
221
+ type="button"
222
+ aria-label={`Remove ${col.label} filter`}
223
+ onClick={() => onRemove(filter.id)}
224
+ className={cn(
225
+ "inline-flex cursor-pointer items-center justify-center h-6 w-5 rounded-e transition-colors",
226
+ "text-muted-foreground hover:text-destructive",
227
+ isActive ? "hover:bg-brand/15" : "hover:bg-interactive-hover",
228
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
229
+ )}
230
+ >
231
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
232
+ </button>
233
+ </div>
234
+ </PopoverAnchor>
235
+
236
+ <PopoverContent
237
+ className={cn(
238
+ "p-0",
239
+ filterDef.type === "date"
240
+ ? "w-auto max-w-[min(calc(100vw-2rem),22rem)]"
241
+ : "w-64",
242
+ )}
243
+ align="start"
244
+ onFocusOutside={e => e.preventDefault()}
245
+ onInteractOutside={e => {
246
+ if (justAutoOpenedRef.current) {
247
+ e.preventDefault()
248
+ justAutoOpenedRef.current = false
249
+ }
250
+ }}
251
+ >
252
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
253
+ <div className="flex items-center gap-1 text-sm text-foreground">
254
+ <span className="font-medium">{col.label}</span>
255
+ <button
256
+ type="button"
257
+ onClick={cycleOperator}
258
+ className="inline-flex items-center gap-0.5 text-muted-foreground hover:text-interactive-hover-foreground transition-colors rounded px-1 py-0.5 hover:bg-interactive-hover"
259
+ >
260
+ {OPERATOR_LABELS[filter.operator]}
261
+ <i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
262
+ </button>
263
+ </div>
264
+ <button
265
+ type="button"
266
+ aria-label="Remove filter"
267
+ onClick={() => onRemove(filter.id)}
268
+ className="text-muted-foreground hover:text-destructive transition-colors p-1 rounded hover:bg-interactive-hover"
269
+ >
270
+ <i className="fa-light fa-trash text-xs" aria-hidden="true" />
271
+ </button>
272
+ </div>
273
+
274
+ {filterDef.type === "date" && (
275
+ <div className="p-2">
276
+ <FilterDateCalendar
277
+ label={`${col.label} — choose date`}
278
+ valueYmd={filter.values[0]}
279
+ onChangeYmd={(ymd) =>
280
+ onUpdate(filter.id, { values: ymd ? [ymd] : [] })
281
+ }
282
+ />
283
+ </div>
284
+ )}
285
+
286
+ {filterDef.type === "select" && (
287
+ <div className="py-1 max-h-64 overflow-y-auto">
288
+ {showSearch && (
289
+ <div className="px-2 pt-1 pb-1">
290
+ <div className="relative">
291
+ <Input
292
+ type="text"
293
+ placeholder="Search options…"
294
+ value={optSearch}
295
+ onChange={e => setOptSearch(e.target.value)}
296
+ className={cn("h-7 text-xs", optSearch ? "pe-8" : "pe-2")}
297
+ autoFocus
298
+ />
299
+ {optSearch ? (
300
+ <button
301
+ type="button"
302
+ aria-label="Clear option search"
303
+ onClick={() => setOptSearch("")}
304
+ className="absolute end-1 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
305
+ >
306
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
307
+ </button>
308
+ ) : null}
309
+ </div>
310
+ </div>
311
+ )}
312
+ {filteredOpts.map(opt => {
313
+ const checked = filter.values.includes(opt.value)
314
+ return (
315
+ <div
316
+ key={opt.value}
317
+ role="option"
318
+ aria-selected={checked}
319
+ tabIndex={0}
320
+ onClick={() => toggleValue(opt.value)}
321
+ onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleValue(opt.value) } }}
322
+ className="flex w-full items-center gap-2.5 px-3 py-1.5 text-sm hover:bg-interactive-hover transition-colors cursor-pointer select-none focus-visible:outline-none focus-visible:bg-interactive-hover"
323
+ >
324
+ <span
325
+ aria-hidden="true"
326
+ data-slot="checkbox"
327
+ data-state={checked ? "checked" : "unchecked"}
328
+ className={cn(
329
+ "inline-flex items-center justify-center size-3.5 shrink-0 rounded-[4px] border transition-colors",
330
+ checked ? "bg-primary border-primary text-primary-foreground" : "border-input bg-background"
331
+ )}
332
+ >
333
+ {checked && <i className="fa-solid fa-check text-current" style={{ fontSize: "8px" }} />}
334
+ </span>
335
+ {renderOptionValue
336
+ ? renderOptionValue(filter.fieldKey, opt.value)
337
+ : <span className="text-foreground">{opt.label}</span>
338
+ }
339
+ </div>
340
+ )
341
+ })}
342
+ {filteredOpts.length === 0 && (
343
+ <p className="px-3 py-2 text-xs text-muted-foreground">No options found</p>
344
+ )}
345
+ </div>
346
+ )}
347
+
348
+ {filterDef.type === "text" && (
349
+ <div className="p-2">
350
+ <FilterTextValueInput
351
+ mask={filterDef.textMask}
352
+ placeholder={`Enter ${col.label.toLowerCase()}…`}
353
+ value={filter.values[0] ?? ""}
354
+ onValueChange={next => onUpdate(filter.id, { values: [next] })}
355
+ aria-label={`${col.label} filter value`}
356
+ className="h-8 text-xs focus-visible:border-ring focus-visible:ring-ring/50"
357
+ autoFocus
358
+ />
359
+ </div>
360
+ )}
361
+ {hasSelection ? (
362
+ <div className="sticky bottom-0 border-t border-border bg-popover p-2">
363
+ <Button
364
+ type="button"
365
+ variant="outline"
366
+ size="sm"
367
+ onClick={() => onUpdate(filter.id, { values: [] })}
368
+ className="w-full justify-center gap-1.5 text-xs text-muted-foreground"
369
+ >
370
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
371
+ Clear selection
372
+ </Button>
373
+ </div>
374
+ ) : null}
375
+ </PopoverContent>
376
+ </Popover>
377
+ )
378
+ }
379
+
380
+ // React.memo wrapper — preserves generic signature via cast.
381
+ // FilterPillBase is a pure function of its props; memoizing it prevents
382
+ // re-renders when unrelated table state (hover, scroll) changes.
383
+ const FilterPill = React.memo(FilterPillBase) as typeof FilterPillBase
384
+
385
+ // ─────────────────────────────────────────────────────────────────────────────
386
+ // Sticky shadow utility
387
+ // ─────────────────────────────────────────────────────────────────────────────
388
+
389
+ function stickyShadow(pin: "left" | "right" | undefined): string {
390
+ if (!pin) return ""
391
+ const base = "after:content-[''] after:absolute after:top-0 after:bottom-0 after:w-3 after:pointer-events-none"
392
+ if (pin === "left") {
393
+ return cn(
394
+ base,
395
+ "after:start-full",
396
+ "after:bg-[linear-gradient(to_right,var(--sticky-edge-fade),transparent)]",
397
+ )
398
+ }
399
+ return cn(
400
+ base,
401
+ "after:end-full",
402
+ "after:bg-[linear-gradient(to_left,var(--sticky-edge-fade),transparent)]",
403
+ )
404
+ }
405
+
406
+ // ─────────────────────────────────────────────────────────────────────────────
407
+ // DataTableToolbar — search, filter bar, properties slot (shared by table + board)
408
+ // ─────────────────────────────────────────────────────────────────────────────
409
+
410
+ export function DataTableToolbar<TData extends Record<string, unknown>>({
411
+ state,
412
+ columns,
413
+ searchable = true,
414
+ /** When false, hides filter pills, search, and filter controls (e.g. dashboard canvas edit mode). */
415
+ showQueryControls = true,
416
+ renderFilterOptionValue,
417
+ toolbarSlot,
418
+ searchAriaLabel = "Search table",
419
+ }: {
420
+ state: ReturnType<typeof useTableState<TData>>
421
+ columns: ColumnDef<TData>[]
422
+ searchable?: boolean
423
+ showQueryControls?: boolean
424
+ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode
425
+ toolbarSlot?: (state: ReturnType<typeof useTableState<TData>>) => React.ReactNode
426
+ /** Passed to the search input `aria-label` (e.g. "Search placements") */
427
+ searchAriaLabel?: string
428
+ }) {
429
+ const {
430
+ search, setSearch, searchOpen, setSearchOpen, searchRef,
431
+ activeFilters, setActiveFilters, openFilterId,
432
+ filterBarVisible, setFilterBarVisible,
433
+ addFilter, updateFilter, removeFilter,
434
+ } = state
435
+
436
+ const filterableCols = columns.filter(c => c.filter)
437
+ const searchModLabel = useModKeyLabel()
438
+ const effectiveSearchable = showQueryControls && searchable
439
+
440
+ React.useEffect(() => {
441
+ if (!effectiveSearchable) return
442
+ function onGlobalKeyDown(e: KeyboardEvent) {
443
+ if (!e.metaKey && !e.ctrlKey) return
444
+ if (e.altKey) return
445
+ if (e.key.toLowerCase() !== "k") return
446
+ if (isEditableTarget(e.target)) return
447
+ e.preventDefault()
448
+ setSearchOpen(true)
449
+ queueMicrotask(() => searchRef.current?.focus())
450
+ }
451
+ document.addEventListener("keydown", onGlobalKeyDown)
452
+ return () => document.removeEventListener("keydown", onGlobalKeyDown)
453
+ }, [effectiveSearchable, setSearchOpen, searchRef])
454
+
455
+ return (
456
+ <div
457
+ className={cn(
458
+ "flex items-center gap-1.5 px-4 lg:px-6",
459
+ showQueryControls ? "min-h-10 pt-2 pb-2" : "min-h-0 justify-end py-1.5",
460
+ )}
461
+ >
462
+
463
+ {showQueryControls && filterBarVisible && filterableCols.length > 0 && (
464
+ <div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
465
+ {activeFilters.map(filter => (
466
+ <React.Fragment key={filter.id}>
467
+ <FilterPill
468
+ filter={filter}
469
+ columns={columns}
470
+ defaultOpen={filter.id === openFilterId}
471
+ onUpdate={updateFilter}
472
+ onRemove={removeFilter}
473
+ renderOptionValue={renderFilterOptionValue}
474
+ />
475
+ </React.Fragment>
476
+ ))}
477
+
478
+ <DropdownMenu>
479
+ <DropdownMenuTrigger asChild>
480
+ <button type="button"
481
+ className="inline-flex cursor-pointer items-center gap-1 h-6 px-2 rounded text-xs text-muted-foreground hover:text-interactive-hover-foreground border border-dashed border-input/70 hover:border-input hover:bg-interactive-hover-subtle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
482
+ >
483
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
484
+ Add filter
485
+ </button>
486
+ </DropdownMenuTrigger>
487
+ <DropdownMenuContent align="start">
488
+ <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
489
+ <DropdownMenuSeparator />
490
+ {filterableCols.map(c => (
491
+ <DropdownMenuItem key={c.key} onClick={() => addFilter(c.key)}>
492
+ {c.filter?.icon && <i className={`fa-light ${c.filter.icon}`} aria-hidden="true" />}
493
+ {c.label}
494
+ </DropdownMenuItem>
495
+ ))}
496
+ </DropdownMenuContent>
497
+ </DropdownMenu>
498
+
499
+ {activeFilters.length > 0 && (
500
+ <button
501
+ type="button"
502
+ onClick={() => setActiveFilters([])}
503
+ className="cursor-pointer text-xs text-muted-foreground hover:text-interactive-hover-foreground transition-colors px-1"
504
+ >
505
+ Clear all
506
+ </button>
507
+ )}
508
+ </div>
509
+ )}
510
+
511
+ <div
512
+ className={cn(
513
+ "flex items-center gap-1 shrink-0",
514
+ showQueryControls && "ms-auto",
515
+ )}
516
+ >
517
+
518
+ {effectiveSearchable && (
519
+ searchOpen ? (
520
+ <div className="relative flex items-center">
521
+ <i className="fa-light fa-magnifying-glass absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground text-xs pointer-events-none" aria-hidden="true" />
522
+ <Input
523
+ ref={searchRef}
524
+ type="text"
525
+ role="searchbox"
526
+ inputMode="search"
527
+ autoComplete="off"
528
+ placeholder="Search…"
529
+ value={search}
530
+ onChange={e => setSearch(e.target.value)}
531
+ onBlur={() => { if (!search) setSearchOpen(false) }}
532
+ onKeyDown={e => { if (e.key === "Escape") { setSearch(""); setSearchOpen(false) } }}
533
+ className={cn("h-8 w-48 ps-7 text-xs", search ? "pe-8" : "pe-2")}
534
+ aria-label={searchAriaLabel}
535
+ />
536
+ {search ? (
537
+ <button
538
+ type="button"
539
+ aria-label="Clear search"
540
+ onClick={() => setSearch("")}
541
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 inline-flex cursor-pointer size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
542
+ >
543
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
544
+ </button>
545
+ ) : null}
546
+ </div>
547
+ ) : (
548
+ <TooltipProvider>
549
+ <Tooltip>
550
+ <TooltipTrigger asChild>
551
+ <button type="button" aria-label="Search"
552
+ onClick={() => { setSearchOpen(true); setTimeout(() => searchRef.current?.focus(), 10) }}
553
+ className="inline-flex shrink-0 cursor-pointer items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
554
+ >
555
+ <i className="fa-light fa-magnifying-glass text-[13px]" aria-hidden="true" />
556
+ </button>
557
+ </TooltipTrigger>
558
+ <TooltipContent side="bottom">
559
+ <span>{searchAriaLabel}</span>
560
+ <KbdGroup>
561
+ <Kbd>{searchModLabel}</Kbd>
562
+ <Kbd>K</Kbd>
563
+ </KbdGroup>
564
+ </TooltipContent>
565
+ </Tooltip>
566
+ </TooltipProvider>
567
+ )
568
+ )}
569
+
570
+ {showQueryControls && filterableCols.length > 0 && (
571
+ <>
572
+ <div className="h-4 w-px bg-border/70" aria-hidden="true" />
573
+ {activeFilters.length > 0 ? (
574
+ <TooltipProvider>
575
+ <Tooltip>
576
+ <TooltipTrigger asChild>
577
+ <button type="button"
578
+ aria-label={filterBarVisible ? "Hide filters" : "Show filters"}
579
+ onClick={() => setFilterBarVisible(v => !v)}
580
+ className={cn(
581
+ "inline-flex shrink-0 cursor-pointer items-center gap-1 size-8 justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
582
+ filterBarVisible
583
+ ? "bg-accent text-accent-foreground hover:bg-accent/90"
584
+ : "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover",
585
+ )}
586
+ >
587
+ <i className="fa-light fa-filter text-[13px]" aria-hidden="true" />
588
+ <span className="text-xs font-semibold tabular-nums">{activeFilters.length}</span>
589
+ </button>
590
+ </TooltipTrigger>
591
+ <TooltipContent side="bottom">
592
+ {filterBarVisible ? "Hide filters" : "Show filters"}
593
+ </TooltipContent>
594
+ </Tooltip>
595
+ </TooltipProvider>
596
+ ) : (
597
+ // NOTE: Tooltip MUST wrap DropdownMenuTrigger directly (not the
598
+ // surrounding <DropdownMenu> wrapper). Radix `asChild` Slot needs
599
+ // a real DOM child — `<DropdownMenu>` is a logical wrapper, so
600
+ // putting it inside TooltipTrigger swallows the tooltip handlers
601
+ // and the hover hint silently disappears.
602
+ <DropdownMenu>
603
+ <TooltipProvider>
604
+ <Tooltip>
605
+ <TooltipTrigger asChild>
606
+ <DropdownMenuTrigger asChild>
607
+ <button type="button" aria-label="Add filter"
608
+ onClick={() => setFilterBarVisible(true)}
609
+ className="inline-flex shrink-0 cursor-pointer items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
610
+ >
611
+ <i className="fa-light fa-filter text-[13px]" aria-hidden="true" />
612
+ </button>
613
+ </DropdownMenuTrigger>
614
+ </TooltipTrigger>
615
+ <TooltipContent side="bottom">Add filter</TooltipContent>
616
+ </Tooltip>
617
+ </TooltipProvider>
618
+ <DropdownMenuContent align="end">
619
+ <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
620
+ <DropdownMenuSeparator />
621
+ {filterableCols.map(c => (
622
+ <DropdownMenuItem key={c.key} onClick={() => addFilter(c.key)}>
623
+ {c.filter?.icon && <i className={`fa-light ${c.filter.icon}`} aria-hidden="true" />}
624
+ {c.label}
625
+ </DropdownMenuItem>
626
+ ))}
627
+ </DropdownMenuContent>
628
+ </DropdownMenu>
629
+ )}
630
+ </>
631
+ )}
632
+
633
+ {toolbarSlot && toolbarSlot(state)}
634
+ </div>
635
+ </div>
636
+ )
637
+ }
638
+
639
+ // ─────────────────────────────────────────────────────────────────────────────
640
+ // DataTable<TData>
641
+ // ─────────────────────────────────────────────────────────────────────────────
642
+
643
+ export interface DataTableExtendedProps<TData extends Record<string, unknown>>
644
+ extends DataTableProps<TData> {
645
+ /** Slot for a toolbar drawer button + drawer itself (e.g. TablePropertiesDrawer) */
646
+ toolbarSlot?: (state: ReturnType<typeof useTableState<TData>>) => React.ReactNode
647
+ /** Slot rendered inside the floating bulk-action bar (after the "N selected" label) */
648
+ bulkActionsSlot?: (selected: Set<string | number>, rows: TData[]) => React.ReactNode
649
+ /** Optional "add new row" row text — pass false to hide */
650
+ addRowLabel?: string | false
651
+ /** Custom option-value renderer for filter pills */
652
+ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode
653
+ /** When set by DataTablePaginated — drives row slicing inside useTableState */
654
+ paginationOverride?: { page: number; pageSize: number }
655
+ /** When true, removes rounded bottom corners so a pagination bar can attach flush */
656
+ hasFooter?: boolean
657
+ /** Conditional formatting rules — apply bg color to cells based on value */
658
+ conditionalRules?: import("./types").ConditionalRule[]
659
+ /** When false, the column header row is hidden (Display options). */
660
+ showColumnHeaders?: boolean
661
+ /** When set, table uses this state (e.g. shared with board view) instead of internal useTableState. */
662
+ state?: ReturnType<typeof useTableState<TData>>
663
+ }
664
+
665
+ type DataTableInnerProps<TData extends Record<string, unknown>> = DataTableExtendedProps<TData> & {
666
+ state: ReturnType<typeof useTableState<TData>>
667
+ }
668
+
669
+ /** Max width for bulk bar in normal (non-reflow) zoom — ~28rem, centered in table. */
670
+ const BULK_BAR_MAX_PX = 448
671
+
672
+ /**
673
+ * When the app theme is `dark`, the bulk strip is a **light** surface; shadcn
674
+ * “dark:” button tokens are wrong — reapply light-look solid/outline/destructive/ghost.
675
+ */
676
+ const BULK_BAR_ON_LIGHT_STRIP = cn(
677
+ "[&_button[data-variant=default]]:bg-zinc-900 [&_button[data-variant=default]]:text-zinc-50",
678
+ "hover:[&_button[data-variant=default]]:bg-zinc-800",
679
+ "[&_button[data-variant=outline]]:border-zinc-300/80 [&_button[data-variant=outline]]:bg-white [&_button[data-variant=outline]]:text-zinc-900",
680
+ "hover:[&_button[data-variant=outline]]:bg-zinc-100",
681
+ "[&_button[data-variant=destructive]]:border-rose-200/80 [&_button[data-variant=destructive]]:bg-rose-100 [&_button[data-variant=destructive]]:text-rose-800",
682
+ "hover:[&_button[data-variant=destructive]]:bg-rose-200/40",
683
+ "[&_button[data-variant=ghost]]:text-zinc-600 hover:[&_button[data-variant=ghost]]:bg-zinc-200/70 hover:[&_button[data-variant=ghost]]:text-zinc-900",
684
+ )
685
+
686
+ /**
687
+ * Pins the bulk bar to the viewport bottom, aligned to the table scroll
688
+ * wrapper. When `fullWidth` is false (normal zoom), width is
689
+ * `min(tableWidth, 28rem)` and centered; when true (reflow), matches table
690
+ * width.
691
+ */
692
+ function useBulkBarFixedToTableScrollEl(
693
+ scrollRef: React.RefObject<HTMLDivElement | null>,
694
+ active: boolean,
695
+ fullWidth: boolean,
696
+ ): React.CSSProperties | undefined {
697
+ const [style, setStyle] = React.useState<React.CSSProperties | undefined>(undefined)
698
+ React.useLayoutEffect(() => {
699
+ if (!active) {
700
+ setStyle(undefined)
701
+ return
702
+ }
703
+ const el = scrollRef.current
704
+ if (!el) {
705
+ setStyle(undefined)
706
+ return
707
+ }
708
+ const apply = () => {
709
+ const r = el.getBoundingClientRect()
710
+ let left = r.left
711
+ let width = r.width
712
+ if (!fullWidth) {
713
+ const w = Math.min(r.width, BULK_BAR_MAX_PX)
714
+ left = r.left + (r.width - w) / 2
715
+ width = w
716
+ }
717
+ setStyle({
718
+ position: "fixed",
719
+ left,
720
+ width,
721
+ bottom: "max(0.5rem, env(safe-area-inset-bottom, 0px))",
722
+ zIndex: 50,
723
+ boxSizing: "border-box",
724
+ margin: 0,
725
+ right: "auto",
726
+ })
727
+ }
728
+ apply()
729
+ // rAF-coalesce so a single frame handles bursts of capture-phase scroll
730
+ // events plus the ResizeObserver firing — instead of N getBoundingClientRect
731
+ // + setState per second.
732
+ const scheduled = rafThrottle(apply)
733
+ const ro = new ResizeObserver(scheduled)
734
+ ro.observe(el)
735
+ window.addEventListener("resize", scheduled, { passive: true })
736
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
737
+ return () => {
738
+ scheduled.cancel()
739
+ ro.disconnect()
740
+ window.removeEventListener("resize", scheduled)
741
+ window.removeEventListener("scroll", scheduled, { capture: true })
742
+ }
743
+ }, [active, fullWidth, scrollRef])
744
+ return style
745
+ }
746
+
747
+ function DataTableInner<TData extends Record<string, unknown>>({
748
+ // `data` / `defaultSort` flow into `useTableState` upstream; the inner table
749
+ // reads them via `state` and never directly here. Keep the prop slots so
750
+ // the public `DataTable<TData>` API stays unchanged.
751
+ data: _data,
752
+ columns,
753
+ getRowId: getRowIdProp,
754
+ getRowSelectionLabel,
755
+ selectable = true,
756
+ searchable = true,
757
+ emptyState,
758
+ onRowClick,
759
+ defaultSort: _defaultSort,
760
+ toolbarSlot,
761
+ bulkActionsSlot,
762
+ addRowLabel = false,
763
+ renderFilterOptionValue,
764
+ hasFooter = false,
765
+ conditionalRules,
766
+ showColumnHeaders = true,
767
+ state,
768
+ }: DataTableInnerProps<TData>) {
769
+ const {
770
+ setSortRules,
771
+ sortKey, sortDir,
772
+ handleSortByKey,
773
+ addFilter,
774
+ groupBy, setGroupBy,
775
+ colMenuSearch, setColMenuSearch,
776
+ selected, setSelected, toggleRow, toggleAll, getRowId,
777
+ colWidths, startResize,
778
+ colPins, lockedPins,
779
+ pinColumn, unpinColumn,
780
+ colWrap, toggleWrap,
781
+ draggedKey, dragOverKey,
782
+ handleDragStart, handleDragOver, handleDrop, handleDragEnd,
783
+ scrollRef, handleScroll, checkOverflow,
784
+ isOverflowing,
785
+ setHoveredRow,
786
+ rows, pagedRows, groupedRows,
787
+ effectivePins, displayCols,
788
+ isReflowViewport,
789
+ stickyStyle,
790
+ totalWidth,
791
+ rowHeight,
792
+ showGridlines,
793
+ setSheetOpen,
794
+ setSheetInitialPanel,
795
+ } = state
796
+
797
+ // Mount overflow check + scrollport width for sticky group headers on horizontal scroll.
798
+ React.useEffect(() => {
799
+ const syncScrollport = () => {
800
+ const el = scrollRef.current
801
+ if (el) {
802
+ el.style.setProperty("--dt-scrollport-width", `${el.clientWidth}px`)
803
+ }
804
+ checkOverflow()
805
+ }
806
+ syncScrollport()
807
+ const el = scrollRef.current
808
+ if (!el) return
809
+ const ro = new ResizeObserver(syncScrollport)
810
+ ro.observe(el)
811
+ return () => ro.disconnect()
812
+ // eslint-disable-next-line react-hooks/exhaustive-deps
813
+ }, [])
814
+
815
+ /** Pending action queued from a column-menu item that should run *after* the menu
816
+ * has fully closed. The Properties drawer is a non-modal Radix Sheet (`modal=false`)
817
+ * and Radix's DropdownMenu close cycle races with the Sheet's `onInteractOutside`
818
+ * / focus-return logic if we open the Sheet synchronously from `onSelect` / `onClick`.
819
+ * `onCloseAutoFocus` on `DropdownMenuContent` is the Radix-native hook that fires
820
+ * after the menu has unmounted and focus has returned — the safe moment to act. */
821
+ const columnMenuPendingActionRef = React.useRef<(() => void) | null>(null)
822
+
823
+ /** One-time horizontal nudge when the grid overflows and pins are active — hints that more columns scroll (overlay scrollbars, esp. Windows, are often invisible until interaction). */
824
+ const pinnedScrollHintDoneRef = React.useRef(false)
825
+ React.useEffect(() => {
826
+ if (!isOverflowing || isReflowViewport || Object.keys(colPins).length === 0) return
827
+ if (pinnedScrollHintDoneRef.current) return
828
+ const el = scrollRef.current
829
+ if (!el) return
830
+ if (el.scrollLeft > 2) return
831
+ const maxScroll = el.scrollWidth - el.clientWidth
832
+ if (maxScroll < 16) return
833
+ if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
834
+ pinnedScrollHintDoneRef.current = true
835
+ return
836
+ }
837
+
838
+ pinnedScrollHintDoneRef.current = true
839
+ const delta = Math.min(96, Math.max(28, Math.round(maxScroll * 0.14)))
840
+ const startDelayMs = 320
841
+ const dwellMs = 520
842
+
843
+ const t1 = window.setTimeout(() => {
844
+ el.scrollTo({ left: delta, behavior: "smooth" })
845
+ }, startDelayMs)
846
+ const t2 = window.setTimeout(() => {
847
+ el.scrollTo({ left: 0, behavior: "smooth" })
848
+ }, startDelayMs + dwellMs)
849
+
850
+ return () => {
851
+ window.clearTimeout(t1)
852
+ window.clearTimeout(t2)
853
+ }
854
+ }, [isOverflowing, isReflowViewport, colPins, scrollRef])
855
+
856
+ const lastLeftPinKey = [...displayCols].reverse().find(c => effectivePins[c.key] === "left")?.key
857
+ const firstRightPinKey = displayCols.find(c => effectivePins[c.key] === "right")?.key
858
+
859
+ function floatingHeaderPinnedStyle(key: string): React.CSSProperties | undefined {
860
+ const pin = effectivePins[key]
861
+ if (!pin) return undefined
862
+
863
+ const visibleWidth =
864
+ typeof floatingHeaderStyle?.width === "number"
865
+ ? floatingHeaderStyle.width
866
+ : tableWrapRef.current?.clientWidth ?? floatingHeaderTableWidth
867
+ const maxScroll = Math.max(0, floatingHeaderTableWidth - visibleWidth)
868
+ const translateX = pin === "left"
869
+ ? headerScrollLeft
870
+ : headerScrollLeft - maxScroll
871
+
872
+ // The floating sticky header is horizontally translated as one table.
873
+ // Counter-translate pinned header cells so they remain locked to the viewport edge.
874
+ return { position: "relative", transform: `translateX(${translateX}px)` }
875
+ }
876
+
877
+ // Row IDs for the current visible rows
878
+ const allRowIds = rows.map((r, i) => getRowId(r, i, getRowIdProp))
879
+ const allSelected = rows.length > 0 && selected.size === rows.length
880
+ const someSelected = selected.size > 0 && !allSelected
881
+ const anySelected = selected.size > 0
882
+
883
+ const { resolvedTheme } = useTheme()
884
+ const isAppDark = resolvedTheme === "dark"
885
+
886
+ const bulkBarUseFixedLayout = anySelected
887
+ /** Reflow: bar spans table width. Normal zoom: bar centered, max 28rem. */
888
+ const bulkBarFixedStyle = useBulkBarFixedToTableScrollEl(
889
+ scrollRef,
890
+ bulkBarUseFixedLayout,
891
+ isReflowViewport,
892
+ )
893
+ const tableWrapRef = React.useRef<HTMLDivElement | null>(null)
894
+ const tableHeadRef = React.useRef<HTMLTableSectionElement | null>(null)
895
+ const [headerIsStuck, setHeaderIsStuck] = React.useState(false)
896
+ const [headerScrollLeft, setHeaderScrollLeft] = React.useState(0)
897
+ const [floatingHeaderStyle, setFloatingHeaderStyle] = React.useState<React.CSSProperties | undefined>(undefined)
898
+ const [floatingHeaderTableWidth, setFloatingHeaderTableWidth] = React.useState(totalWidth)
899
+ const [isClient, setIsClient] = React.useState(false)
900
+
901
+ React.useEffect(() => {
902
+ setIsClient(true)
903
+ }, [])
904
+
905
+ React.useEffect(() => {
906
+ const wrapEl = tableWrapRef.current
907
+ const headEl = tableHeadRef.current
908
+ if (!wrapEl || !headEl || !showColumnHeaders) {
909
+ setHeaderIsStuck(false)
910
+ return
911
+ }
912
+
913
+ const update = () => {
914
+ const wrapRect = wrapEl.getBoundingClientRect()
915
+ const headHeight = headEl.getBoundingClientRect().height || 0
916
+ const rootStyle = getComputedStyle(document.documentElement)
917
+ const headerOffset = Number.parseFloat(rootStyle.getPropertyValue("--header-height")) || 0
918
+ const stuck = wrapRect.top <= headerOffset && wrapRect.bottom > (headHeight + headerOffset + 1)
919
+ setHeaderIsStuck(prev => (prev === stuck ? prev : stuck))
920
+ }
921
+
922
+ update()
923
+ // rAF-coalesce: capture-phase scroll fires for every ancestor (sidebar,
924
+ // dashboard panels, anchored sheets), so a single getBoundingClientRect
925
+ // per frame is more than enough to keep the sticky header aligned.
926
+ const scheduled = rafThrottle(update)
927
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
928
+ window.addEventListener("resize", scheduled, { passive: true })
929
+ return () => {
930
+ scheduled.cancel()
931
+ window.removeEventListener("scroll", scheduled, { capture: true })
932
+ window.removeEventListener("resize", scheduled)
933
+ }
934
+ }, [showColumnHeaders, rows.length, displayCols.length])
935
+
936
+ React.useLayoutEffect(() => {
937
+ if (!headerIsStuck || !showColumnHeaders) {
938
+ setFloatingHeaderStyle(undefined)
939
+ return
940
+ }
941
+ const wrapEl = tableWrapRef.current
942
+ if (!wrapEl) {
943
+ setFloatingHeaderStyle(undefined)
944
+ return
945
+ }
946
+
947
+ const apply = () => {
948
+ const rect = wrapEl.getBoundingClientRect()
949
+ const rootStyle = getComputedStyle(document.documentElement)
950
+ const headerOffset = Number.parseFloat(rootStyle.getPropertyValue("--header-height")) || 0
951
+ const cs = getComputedStyle(wrapEl)
952
+ const borderLeft = parseFloat(cs.borderLeftWidth) || 0
953
+ const borderRight = parseFloat(cs.borderRightWidth) || 0
954
+ const visibleWidth = Math.max(0, wrapEl.clientWidth - borderLeft - borderRight)
955
+ const renderedTableWidth = Math.max(
956
+ totalWidth,
957
+ visibleWidth,
958
+ wrapEl.querySelector("table")?.getBoundingClientRect().width ?? 0,
959
+ )
960
+ setFloatingHeaderStyle({
961
+ position: "fixed",
962
+ top: headerOffset,
963
+ left: rect.left + borderLeft,
964
+ width: visibleWidth,
965
+ zIndex: 50,
966
+ })
967
+ setFloatingHeaderTableWidth(renderedTableWidth)
968
+ setHeaderScrollLeft(wrapEl.scrollLeft)
969
+ }
970
+
971
+ apply()
972
+ const scheduled = rafThrottle(apply)
973
+ const ro = new ResizeObserver(scheduled)
974
+ ro.observe(wrapEl)
975
+ window.addEventListener("scroll", scheduled, { passive: true, capture: true })
976
+ window.addEventListener("resize", scheduled, { passive: true })
977
+ return () => {
978
+ scheduled.cancel()
979
+ ro.disconnect()
980
+ window.removeEventListener("scroll", scheduled, { capture: true })
981
+ window.removeEventListener("resize", scheduled)
982
+ }
983
+ }, [headerIsStuck, showColumnHeaders, totalWidth, displayCols.length])
984
+
985
+ function ariaSortAttr(colKey: string): React.AriaAttributes["aria-sort"] {
986
+ return sortKey !== colKey ? "none" : sortDir === "asc" ? "ascending" : "descending"
987
+ }
988
+
989
+ function cellStyle(key: string): React.CSSProperties {
990
+ return stickyStyle(key)
991
+ }
992
+
993
+ // ─── Render ───────────────────────────────────────────────────────────────
994
+ return (
995
+ <div className="flex min-w-0 w-full flex-col gap-0">
996
+
997
+ <DataTableToolbar
998
+ state={state}
999
+ columns={columns}
1000
+ searchable={searchable}
1001
+ renderFilterOptionValue={renderFilterOptionValue}
1002
+ toolbarSlot={toolbarSlot}
1003
+ searchAriaLabel="Search table"
1004
+ />
1005
+
1006
+ {isClient && showColumnHeaders && headerIsStuck && floatingHeaderStyle
1007
+ ? createPortal(
1008
+ <div
1009
+ style={floatingHeaderStyle}
1010
+ className="pointer-events-auto"
1011
+ >
1012
+ <div className="overflow-hidden border border-border bg-dt-header-bg shadow-[0_10px_18px_-14px_rgba(15,23,42,0.5)] dark:shadow-[0_12px_20px_-14px_rgba(0,0,0,0.75)]">
1013
+ <div style={{ transform: `translateX(${-headerScrollLeft}px)` }}>
1014
+ <table
1015
+ className="w-full text-sm border-separate border-spacing-0"
1016
+ style={{ tableLayout: "fixed", width: floatingHeaderTableWidth }}
1017
+ >
1018
+ <colgroup>
1019
+ {displayCols.map(col => (
1020
+ <col key={col.key} style={{ width: colWidths[col.key] ?? col.width ?? 100 }} />
1021
+ ))}
1022
+ </colgroup>
1023
+ <thead className="bg-dt-header-bg">
1024
+ <tr>
1025
+ {displayCols.map(col => {
1026
+ const isPinned = !!effectivePins[col.key]
1027
+ const isEdgePinCol = col.key === lastLeftPinKey || col.key === firstRightPinKey
1028
+ return (
1029
+ <th
1030
+ key={col.key}
1031
+ scope="col"
1032
+ style={floatingHeaderPinnedStyle(col.key)}
1033
+ className={cn(
1034
+ "h-9 px-3 text-start align-middle select-none",
1035
+ "text-xs font-medium text-muted-foreground tracking-wide",
1036
+ "bg-dt-header-bg border-b border-border",
1037
+ showGridlines && (!isEdgePinCol
1038
+ ? "border-e border-border last:border-e-0"
1039
+ : "last:border-e-0"),
1040
+ isPinned ? "z-40" : "z-30",
1041
+ isPinned && "relative",
1042
+ isEdgePinCol && stickyShadow(effectivePins[col.key]),
1043
+ )}
1044
+ >
1045
+ <div className="flex items-center justify-between gap-1 min-w-0">
1046
+ <div className="flex items-center min-w-0 flex-1">
1047
+ {col.key === "select" ? (
1048
+ selectable && (
1049
+ <span className="inline-flex items-center justify-center self-center">
1050
+ <span className="sr-only">{resolvedColumnLabel(col)}</span>
1051
+ <Checkbox
1052
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
1053
+ onCheckedChange={() => toggleAll(allRowIds)}
1054
+ aria-label="Select all rows"
1055
+ />
1056
+ </span>
1057
+ )
1058
+ ) : col.sortable && col.sortKey ? (
1059
+ <button
1060
+ type="button"
1061
+ onClick={() => handleSortByKey(col.key)}
1062
+ className={cn(
1063
+ "inline-flex items-center hover:text-interactive-hover-foreground transition-colors whitespace-nowrap",
1064
+ sortKey === col.key && "text-foreground",
1065
+ )}
1066
+ >
1067
+ {col.label?.trim() ? col.label : resolvedColumnLabel(col)}
1068
+ {sortKey === col.key ? <SortChevron dir={sortDir} /> : null}
1069
+ </button>
1070
+ ) : (
1071
+ <span className="truncate whitespace-nowrap">
1072
+ {col.label?.trim()
1073
+ ? col.label
1074
+ : defaultColumnHeaderLabel(col.key) ?? col.key}
1075
+ </span>
1076
+ )}
1077
+ </div>
1078
+ </div>
1079
+ </th>
1080
+ )
1081
+ })}
1082
+ </tr>
1083
+ </thead>
1084
+ </table>
1085
+ </div>
1086
+ </div>
1087
+ </div>,
1088
+ document.body,
1089
+ )
1090
+ : null}
1091
+
1092
+ {/* ── Table ────────────────────────────────────────────────────────── */}
1093
+ <div
1094
+ ref={el => {
1095
+ tableWrapRef.current = el
1096
+ scrollRef.current = el
1097
+ }}
1098
+ onScroll={e => {
1099
+ handleScroll()
1100
+ setHeaderScrollLeft((e.currentTarget as HTMLDivElement).scrollLeft)
1101
+ }}
1102
+ className={cn(
1103
+ "mx-4 lg:mx-6 overflow-x-auto border border-border",
1104
+ hasFooter ? "rounded-t-lg" : "rounded-lg",
1105
+ )}
1106
+ >
1107
+ <table
1108
+ className="w-full text-sm border-separate border-spacing-0"
1109
+ style={{
1110
+ tableLayout: "fixed",
1111
+ minWidth: totalWidth,
1112
+ width: headerIsStuck ? floatingHeaderTableWidth : undefined,
1113
+ }}
1114
+ >
1115
+ <colgroup>
1116
+ {displayCols.map(col => (
1117
+ <col key={col.key} style={{ width: colWidths[col.key] ?? col.width ?? 100 }} />
1118
+ ))}
1119
+ </colgroup>
1120
+
1121
+ {/* ── Table head ──────────────────────────────────────────────── */}
1122
+ <thead
1123
+ ref={tableHeadRef}
1124
+ className={cn(
1125
+ "bg-dt-header-bg",
1126
+ headerIsStuck && "invisible",
1127
+ !showColumnHeaders && "hidden"
1128
+ )}
1129
+ >
1130
+ <tr>
1131
+ {displayCols.map(col => {
1132
+ const isPinned = !!effectivePins[col.key]
1133
+ const isLocked = !!lockedPins[col.key]
1134
+ const isFree = !colPins[col.key]
1135
+ const isResizable = !isLocked || (col.key !== "select")
1136
+
1137
+ const isEdgePinCol = col.key === lastLeftPinKey || col.key === firstRightPinKey
1138
+
1139
+ return (
1140
+ <th
1141
+ key={col.key}
1142
+ scope="col"
1143
+ aria-sort={col.sortable && col.sortKey ? ariaSortAttr(col.sortKey as string) : undefined}
1144
+ draggable={isFree}
1145
+ onDragStart={isFree ? e => handleDragStart(col.key, e) : undefined}
1146
+ onDragOver={isFree ? e => handleDragOver(col.key, e) : undefined}
1147
+ onDrop={isFree ? () => handleDrop(col.key) : undefined}
1148
+ onDragEnd={isFree ? handleDragEnd : undefined}
1149
+ style={stickyStyle(col.key, false)}
1150
+ className={cn(
1151
+ "group/th relative h-9 px-3 text-start align-middle select-none",
1152
+ "text-xs font-medium text-muted-foreground tracking-wide",
1153
+ "bg-dt-header-bg border-b border-border",
1154
+ showGridlines && (!isEdgePinCol
1155
+ ? "border-e border-border last:border-e-0"
1156
+ : "last:border-e-0"),
1157
+ isPinned ? "z-40" : "z-30",
1158
+ isFree && "cursor-grab active:cursor-grabbing",
1159
+ dragOverKey === col.key && draggedKey.current !== col.key && "bg-accent/40",
1160
+ isEdgePinCol && stickyShadow(effectivePins[col.key])
1161
+ )}
1162
+ >
1163
+ <div className="flex items-center justify-between gap-1 min-w-0">
1164
+ <div className="flex items-center min-w-0 flex-1">
1165
+ {col.header ? (
1166
+ col.header()
1167
+ ) : col.key === "select" ? (
1168
+ selectable && (
1169
+ <span className="inline-flex items-center justify-center self-center">
1170
+ <span className="sr-only">{resolvedColumnLabel(col)}</span>
1171
+ <Checkbox
1172
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
1173
+ onCheckedChange={() => toggleAll(allRowIds)}
1174
+ aria-label="Select all rows"
1175
+ />
1176
+ </span>
1177
+ )
1178
+ ) : col.sortable && col.sortKey ? (
1179
+ <Tip label={`Sort by ${resolvedColumnLabel(col)}`} side="top">
1180
+ <button
1181
+ type="button"
1182
+ onClick={() => handleSortByKey(col.key)}
1183
+ className={cn(
1184
+ "inline-flex items-center hover:text-interactive-hover-foreground transition-colors whitespace-nowrap",
1185
+ sortKey === col.key && "text-foreground"
1186
+ )}
1187
+ >
1188
+ {col.label?.trim() ? col.label : resolvedColumnLabel(col)}
1189
+ {sortKey === col.key && <SortChevron dir={sortDir} />}
1190
+ </button>
1191
+ </Tip>
1192
+ ) : (
1193
+ <Tip label={resolvedColumnLabel(col)} side="top">
1194
+ <span className="whitespace-nowrap">
1195
+ {col.label?.trim() ? (
1196
+ col.label
1197
+ ) : defaultColumnHeaderLabel(col.key) ? (
1198
+ <span className="sr-only">{defaultColumnHeaderLabel(col.key)}</span>
1199
+ ) : (
1200
+ <span className="sr-only">{col.key}</span>
1201
+ )}
1202
+ </span>
1203
+ </Tip>
1204
+ )}
1205
+ </div>
1206
+
1207
+ {/* Column context menu — not on checkbox or locked-right columns */}
1208
+ {col.key !== "select" && !lockedPins[col.key]?.includes("right") && col.key !== (columns.find(c => c.lockPin && c.defaultPin === "right")?.key) && (
1209
+ <DropdownMenu>
1210
+ <Tip label="Column options" side="top">
1211
+ <DropdownMenuTrigger asChild>
1212
+ <button
1213
+ type="button"
1214
+ aria-label={`${resolvedColumnLabel(col)} column options`}
1215
+ onClick={e => e.stopPropagation()}
1216
+ className={cn(
1217
+ "opacity-0 group-hover/th:opacity-100 group-focus-within/th:opacity-100",
1218
+ "inline-flex shrink-0 items-center justify-center size-7 rounded-md",
1219
+ "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-row",
1220
+ "transition-opacity focus-visible:opacity-100",
1221
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
1222
+ )}
1223
+ >
1224
+ <i className="fa-light fa-ellipsis-vertical text-xs" aria-hidden="true" />
1225
+ </button>
1226
+ </DropdownMenuTrigger>
1227
+ </Tip>
1228
+ <DropdownMenuContent
1229
+ align="start"
1230
+ onCloseAutoFocus={() => {
1231
+ const action = columnMenuPendingActionRef.current
1232
+ if (!action) return
1233
+ columnMenuPendingActionRef.current = null
1234
+ action()
1235
+ }}
1236
+ >
1237
+
1238
+ {/* Column quick-search */}
1239
+ <div className="px-2 pt-2 pb-1">
1240
+ <div className="relative">
1241
+ <i className="fa-light fa-magnifying-glass absolute start-2 top-1/2 -translate-y-1/2 text-muted-foreground text-xs pointer-events-none" aria-hidden="true" />
1242
+ <Input
1243
+ placeholder={`Search ${resolvedColumnLabel(col)}…`}
1244
+ value={colMenuSearch[col.key] ?? ""}
1245
+ onChange={e => setColMenuSearch(prev => ({ ...prev, [col.key]: e.target.value }))}
1246
+ onKeyDown={e => e.stopPropagation()}
1247
+ className="h-7 ps-6 text-xs"
1248
+ />
1249
+ {colMenuSearch[col.key] && (
1250
+ <button
1251
+ type="button"
1252
+ aria-label="Clear search"
1253
+ onClick={() => setColMenuSearch(prev => ({ ...prev, [col.key]: "" }))}
1254
+ className="absolute end-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-interactive-hover-foreground transition-colors"
1255
+ >
1256
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
1257
+ </button>
1258
+ )}
1259
+ </div>
1260
+ </div>
1261
+ <DropdownMenuSeparator />
1262
+
1263
+ {/* Pin options */}
1264
+ {!isLocked && (
1265
+ <>
1266
+ <DropdownMenuItem
1267
+ onClick={() => pinColumn(col.key, "left")}
1268
+ disabled={colPins[col.key] === "left"}
1269
+ >
1270
+ <i className="fa-light fa-arrow-left-to-line" aria-hidden="true" />
1271
+ Pin Left
1272
+ </DropdownMenuItem>
1273
+ <DropdownMenuItem
1274
+ onClick={() => pinColumn(col.key, "right")}
1275
+ disabled={colPins[col.key] === "right"}
1276
+ >
1277
+ <i className="fa-light fa-arrow-right-to-line" aria-hidden="true" />
1278
+ Pin Right
1279
+ </DropdownMenuItem>
1280
+ {colPins[col.key] && (
1281
+ <DropdownMenuItem onClick={() => unpinColumn(col.key)}>
1282
+ <i className="fa-light fa-thumbtack-slash" aria-hidden="true" />
1283
+ Unpin
1284
+ </DropdownMenuItem>
1285
+ )}
1286
+ <DropdownMenuSeparator />
1287
+ </>
1288
+ )}
1289
+
1290
+ {/* Sort options */}
1291
+ {col.sortable && col.sortKey && (
1292
+ <>
1293
+ <DropdownMenuItem onClick={() => setSortRules(prev => {
1294
+ const filtered = prev.filter(r => r.fieldKey !== col.key)
1295
+ return [{ id: `sort-${Date.now()}`, fieldKey: col.key, direction: "asc" as const }, ...filtered]
1296
+ })}>
1297
+ <i className="fa-light fa-arrow-up-a-z text-xs shrink-0" aria-hidden="true" />
1298
+ Sort Ascending
1299
+ </DropdownMenuItem>
1300
+ <DropdownMenuItem onClick={() => setSortRules(prev => {
1301
+ const filtered = prev.filter(r => r.fieldKey !== col.key)
1302
+ return [{ id: `sort-${Date.now()}`, fieldKey: col.key, direction: "desc" as const }, ...filtered]
1303
+ })}>
1304
+ <i className="fa-light fa-arrow-down-a-z text-xs shrink-0" aria-hidden="true" />
1305
+ Sort Descending
1306
+ </DropdownMenuItem>
1307
+ <DropdownMenuSeparator />
1308
+ </>
1309
+ )}
1310
+
1311
+ {/* Text wrap toggle */}
1312
+ <DropdownMenuItem onClick={() => toggleWrap(col.key)}>
1313
+ <i className="fa-light fa-text-width" aria-hidden="true" />
1314
+ {colWrap[col.key] ? "Unwrap Text" : "Wrap Text"}
1315
+ </DropdownMenuItem>
1316
+
1317
+ {/* Filter / Group by */}
1318
+ <DropdownMenuSeparator />
1319
+ {col.filter && (
1320
+ <DropdownMenuItem onClick={() => addFilter(col.key)}>
1321
+ <i className="fa-light fa-filter" aria-hidden="true" />
1322
+ Filter by this column
1323
+ </DropdownMenuItem>
1324
+ )}
1325
+ <DropdownMenuItem
1326
+ onClick={() => setGroupBy(groupBy === col.key ? null : col.key)}
1327
+ >
1328
+ <i className="fa-light fa-layer-group" aria-hidden="true" />
1329
+ {groupBy === col.key ? "Remove Grouping" : "Group by this Column"}
1330
+ </DropdownMenuItem>
1331
+
1332
+ {/* Conditional rule shortcut */}
1333
+ <DropdownMenuSeparator />
1334
+ <DropdownMenuItem
1335
+ onSelect={() => {
1336
+ // Queue the deep-link into `onCloseAutoFocus` (above).
1337
+ // Opening the non-modal Properties Sheet synchronously
1338
+ // here races with the DropdownMenu close cycle and the
1339
+ // Sheet's outside-interaction listener — neither RAF
1340
+ // nor setTimeout(0) is enough. `onCloseAutoFocus` is
1341
+ // Radix's official "menu is fully closed and focus has
1342
+ // returned" hook, so it's the only safe moment.
1343
+ // Set the panel + open in the same batched action —
1344
+ // both setState calls land in one render so the drawer
1345
+ // opens already focused on the Conditional rules panel.
1346
+ columnMenuPendingActionRef.current = () => {
1347
+ setSheetInitialPanel("conditional-rules")
1348
+ setSheetOpen(true)
1349
+ }
1350
+ }}
1351
+ >
1352
+ <i className="fa-light fa-palette" aria-hidden="true" />
1353
+ Add Conditional Rule
1354
+ </DropdownMenuItem>
1355
+
1356
+ </DropdownMenuContent>
1357
+ </DropdownMenu>
1358
+ )}
1359
+ </div>
1360
+
1361
+ {/* Resize handle */}
1362
+ {isResizable && col.key !== "select" && (
1363
+ <div
1364
+ role="separator"
1365
+ aria-label={`Resize ${resolvedColumnLabel(col)} column`}
1366
+ aria-orientation="vertical"
1367
+ onMouseDown={e => startResize(col.key, e)}
1368
+ className="absolute end-0 top-1 bottom-1 w-1.5 cursor-col-resize rounded-full hover:bg-interactive-hover-foreground/50 active:bg-muted-foreground/70 transition-colors"
1369
+ />
1370
+ )}
1371
+ </th>
1372
+ )
1373
+ })}
1374
+ </tr>
1375
+ </thead>
1376
+
1377
+ {/* ── Table body ───────────────────────────────────────────────── */}
1378
+ <tbody>
1379
+ {(pagedRows !== rows
1380
+ ? [{ groupKey: null as string | null, groupLabel: null as string | null, rows: pagedRows }]
1381
+ : groupedRows
1382
+ ).map(({ groupKey, groupLabel, rows: groupRows }) => (
1383
+ <React.Fragment key={groupKey ?? "__all__"}>
1384
+ {groupLabel && (
1385
+ <tr>
1386
+ <td colSpan={displayCols.length} className="p-0 border-b border-border bg-dt-group-bg">
1387
+ <div
1388
+ className={cn(
1389
+ "sticky start-0 z-[25] px-4 py-1.5 text-xs font-semibold text-muted-foreground tracking-wide bg-dt-group-bg select-none",
1390
+ !isReflowViewport && "shadow-[4px_0_8px_-4px_var(--sticky-edge-fade)]",
1391
+ )}
1392
+ style={{ width: "var(--dt-scrollport-width, 100%)" }}
1393
+ >
1394
+ {groupLabel}
1395
+ <span className="ms-2 font-normal normal-case opacity-60 tracking-normal">
1396
+ {groupRows.length} record{groupRows.length !== 1 ? "s" : ""}
1397
+ </span>
1398
+ </div>
1399
+ </td>
1400
+ </tr>
1401
+ )}
1402
+ {groupRows.map((row, rowIndex) => {
1403
+ const rowId = getRowId(row, rowIndex, getRowIdProp)
1404
+ const isSelected = selected.has(rowId)
1405
+ const rowClickable = Boolean(onRowClick) || selectable
1406
+ function handleRowClick(e: React.MouseEvent<HTMLTableRowElement>) {
1407
+ if (!rowClickable) return
1408
+ const el = e.target as HTMLElement | null
1409
+ if (!el) return
1410
+ if (el.closest("button, a, input, textarea, select, label, [role='checkbox']")) return
1411
+ if (onRowClick) {
1412
+ onRowClick(row)
1413
+ return
1414
+ }
1415
+ if (selectable) {
1416
+ toggleRow(rowId)
1417
+ }
1418
+ }
1419
+ return (
1420
+ <tr
1421
+ key={String(rowId)}
1422
+ data-state={isSelected ? "selected" : undefined}
1423
+ onMouseEnter={() => setHoveredRow(rowId)}
1424
+ onMouseLeave={() => setHoveredRow(null)}
1425
+ onClick={rowClickable ? handleRowClick : undefined}
1426
+ data-new={Boolean((row as Record<string, unknown>).isNew) || undefined}
1427
+ className={cn(
1428
+ "group/row transition-colors",
1429
+ "hover:bg-dt-row-hover",
1430
+ isSelected && "bg-dt-row-selected text-dt-row-selected-fg",
1431
+ rowClickable && "cursor-pointer",
1432
+ Boolean((row as Record<string, unknown>).isNew) && "bg-dt-new-row-bg border-s-2 border-s-dt-new-row-border"
1433
+ )}
1434
+ >
1435
+ {displayCols.map(col => {
1436
+ const isPinned = !!effectivePins[col.key]
1437
+ const wrap = colWrap[col.key]
1438
+ const isEdgePin = col.key === lastLeftPinKey || col.key === firstRightPinKey
1439
+ const rowPy = rowHeight === "compact" ? "py-1" : rowHeight === "comfortable" ? "py-4" : "py-2.5"
1440
+ const cs = cellStyle(col.key)
1441
+
1442
+ const tdBase = cn(
1443
+ `px-3 ${rowPy} align-middle`,
1444
+ showGridlines && !isEdgePin && "border-e border-border last:border-e-0",
1445
+ "border-b border-border group-last/row:border-b-0",
1446
+ isPinned && [
1447
+ "z-20 pinned-cell",
1448
+ "bg-dt-row-bg",
1449
+ "group-data-[state=selected]/row:bg-dt-row-selected",
1450
+ "group-hover/row:bg-dt-row-hover",
1451
+ isEdgePin && stickyShadow(effectivePins[col.key]),
1452
+ ]
1453
+ )
1454
+
1455
+ const conditionalBg = getConditionalCellBackground(
1456
+ row,
1457
+ col.key,
1458
+ conditionalRules,
1459
+ columns,
1460
+ )
1461
+
1462
+ const tdStyle = conditionalBg
1463
+ ? { ...cs, background: conditionalBg }
1464
+ : cs
1465
+
1466
+ // Special synthetic columns
1467
+ if (col.key === "select") {
1468
+ const selectionLabel = getRowSelectionLabel?.(row, rowIndex)
1469
+ const ariaLabel = selectionLabel
1470
+ ? `Select row, ${selectionLabel}`
1471
+ : `Select row ${rowIndex + 1}`
1472
+ return (
1473
+ <td key="select" className={cn(tdBase, "text-center")} style={tdStyle}>
1474
+ {selectable && (
1475
+ // inline-flex: inline elements inside <td> are never
1476
+ // stretched by table-cell height in Chrome/Safari/Firefox.
1477
+ // Block-level flex/grid always inherits full cell height at zoom.
1478
+ <span
1479
+ className={cn(
1480
+ "inline-flex items-center justify-center transition-opacity",
1481
+ anySelected
1482
+ ? "opacity-100"
1483
+ : "opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100",
1484
+ )}
1485
+ onClick={e => e.stopPropagation()}
1486
+ >
1487
+ <Checkbox
1488
+ checked={isSelected}
1489
+ onCheckedChange={() => toggleRow(rowId)}
1490
+ aria-label={ariaLabel}
1491
+ onClick={e => e.stopPropagation()}
1492
+ />
1493
+ </span>
1494
+ )}
1495
+ </td>
1496
+ )
1497
+ }
1498
+
1499
+ // Custom cell renderer
1500
+ if (col.cell) {
1501
+ return (
1502
+ <td
1503
+ key={col.key}
1504
+ className={cn(
1505
+ tdBase,
1506
+ // When wrap is on, override truncate/overflow on any descendant
1507
+ wrap && "[&_.truncate]:!whitespace-normal [&_.truncate]:!overflow-visible [&_.truncate]:!text-clip",
1508
+ )}
1509
+ style={tdStyle}
1510
+ >
1511
+ {col.cell(row, {
1512
+ rowIndex,
1513
+ selected: isSelected,
1514
+ onSelect: checked => checked ? setSelected(prev => new Set([...prev, rowId])) : toggleRow(rowId),
1515
+ })}
1516
+ </td>
1517
+ )
1518
+ }
1519
+
1520
+ // Default: render string value with optional truncation
1521
+ const rawVal = String(row[col.key] ?? "")
1522
+ return (
1523
+ <td key={col.key} className={cn(tdBase, "text-sm text-foreground/80")} style={tdStyle}>
1524
+ <span className={wrap ? "whitespace-normal" : "block truncate"} title={!wrap ? rawVal : undefined}>
1525
+ {rawVal}
1526
+ </span>
1527
+ </td>
1528
+ )
1529
+ })}
1530
+ </tr>
1531
+ )
1532
+ })}
1533
+ </React.Fragment>
1534
+ ))}
1535
+
1536
+ {/* Empty state */}
1537
+ {rows.length === 0 && (
1538
+ <tr>
1539
+ <td colSpan={displayCols.length} className="h-24 px-3 text-center text-sm text-muted-foreground">
1540
+ {emptyState ?? "No results match your filters."}
1541
+ </td>
1542
+ </tr>
1543
+ )}
1544
+
1545
+ {/* Add new row stub */}
1546
+ {addRowLabel !== false && (
1547
+ <tr
1548
+ role="button"
1549
+ tabIndex={0}
1550
+ onKeyDown={e => { if (e.key === "Enter" || e.key === " ") e.preventDefault() }}
1551
+ className="cursor-pointer hover:bg-dt-row-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
1552
+ aria-label={`Add new ${addRowLabel}`}
1553
+ >
1554
+ <td colSpan={displayCols.length} className="px-3 py-2.5 align-middle">
1555
+ <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
1556
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
1557
+ {addRowLabel}
1558
+ </span>
1559
+ </td>
1560
+ </tr>
1561
+ )}
1562
+ </tbody>
1563
+ </table>
1564
+ </div>
1565
+
1566
+ {/* ── Bulk selection bar — dark strip in light app; light strip in dark app.
1567
+ Normal zoom: max ~28rem, centered. Reflow: full table width. Inner
1568
+ `dark` in light app → shadcn `dark:` buttons; in dark app → explicit
1569
+ light-surface button overrides.
1570
+ */}
1571
+ {anySelected && (
1572
+ <div
1573
+ role="status"
1574
+ aria-live="polite"
1575
+ aria-label={`${selected.size} row${selected.size !== 1 ? "s" : ""} selected`}
1576
+ data-exxat-bulk-bar=""
1577
+ style={bulkBarFixedStyle}
1578
+ className={cn(
1579
+ "flex min-w-0 max-w-full items-stretch overflow-hidden",
1580
+ isAppDark
1581
+ ? "rounded-lg border border-zinc-300/80 bg-zinc-100 text-zinc-900 shadow-lg"
1582
+ : "rounded-lg border border-zinc-800 bg-zinc-900 text-zinc-100 shadow-lg",
1583
+ "animate-in fade-in-0 duration-150",
1584
+ "w-auto max-w-none",
1585
+ )}
1586
+ >
1587
+ <div
1588
+ className={cn(
1589
+ "flex shrink-0 items-center gap-2 border-e py-2.5 ps-3 pe-2",
1590
+ isAppDark ? "border-zinc-300/50" : "border-zinc-600/50",
1591
+ )}
1592
+ aria-hidden="true"
1593
+ >
1594
+ <span
1595
+ className={cn(
1596
+ "inline-flex size-8 items-center justify-center rounded-md",
1597
+ isAppDark ? "text-zinc-500" : "text-zinc-400",
1598
+ )}
1599
+ aria-hidden="true"
1600
+ >
1601
+ <i className="fa-light fa-clipboard-list text-[1.1rem] leading-none" />
1602
+ </span>
1603
+ <span
1604
+ className={cn(
1605
+ "min-w-6 rounded-md px-1.5 py-0.5 text-center text-xs font-semibold leading-none tabular-nums",
1606
+ isAppDark ? "bg-zinc-200/90 text-zinc-900" : "bg-zinc-800 text-zinc-100",
1607
+ )}
1608
+ >
1609
+ {selected.size}
1610
+ </span>
1611
+ </div>
1612
+
1613
+ <div
1614
+ className={cn(
1615
+ "flex min-w-0 min-h-0 flex-1 items-stretch",
1616
+ !isAppDark && "dark",
1617
+ isAppDark && BULK_BAR_ON_LIGHT_STRIP,
1618
+ )}
1619
+ >
1620
+ <div
1621
+ className={cn(
1622
+ "min-w-0 flex-1 self-center",
1623
+ "overflow-x-auto overscroll-x-contain [scrollbar-width:thin] [touch-action:pan-x]",
1624
+ )}
1625
+ >
1626
+ <div className="flex w-max min-w-0 max-w-full flex-nowrap items-center gap-2 py-2.5 ps-2 pe-2">
1627
+ {bulkActionsSlot ? (
1628
+ bulkActionsSlot(selected, rows)
1629
+ ) : (
1630
+ <>
1631
+ <Button size="sm" variant="outline" className="shrink-0">
1632
+ <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" /> Export
1633
+ </Button>
1634
+ <Button size="sm" variant="destructive" className="shrink-0">
1635
+ <i className="fa-light fa-trash" aria-hidden="true" /> Delete
1636
+ </Button>
1637
+ </>
1638
+ )}
1639
+ </div>
1640
+ </div>
1641
+
1642
+ <div
1643
+ className={cn(
1644
+ "flex shrink-0 items-center border-e py-2.5 ps-2 pe-2.5",
1645
+ isAppDark ? "border-zinc-300/50" : "border-zinc-600/50",
1646
+ )}
1647
+ >
1648
+ <Tip label="Clear selection" side="top">
1649
+ <Button
1650
+ type="button"
1651
+ size="icon-sm"
1652
+ variant="ghost"
1653
+ aria-label="Clear selection"
1654
+ onClick={() => setSelected(new Set())}
1655
+ className="shrink-0"
1656
+ >
1657
+ <i className="fa-light fa-xmark" aria-hidden="true" />
1658
+ </Button>
1659
+ </Tip>
1660
+ </div>
1661
+ </div>
1662
+ </div>
1663
+ )}
1664
+ </div>
1665
+ )
1666
+ }
1667
+
1668
+ function DataTableWithInternalState<TData extends Record<string, unknown>>(props: DataTableExtendedProps<TData>) {
1669
+ const state = useTableState(props.data, props.columns, props.defaultSort, props.paginationOverride)
1670
+ return <DataTableInner {...props} state={state} />
1671
+ }
1672
+
1673
+ export function DataTable<TData extends Record<string, unknown>>(props: DataTableExtendedProps<TData>) {
1674
+ if (props.state) {
1675
+ return <DataTableInner {...props} state={props.state} />
1676
+ }
1677
+ return <DataTableWithInternalState {...props} />
1678
+ }