@exxatdesignux/ui 0.0.6 → 0.0.7

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