@exxatdesignux/ui 0.0.6 → 0.0.8

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,566 @@
1
+ "use client"
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // useTableState — all non-display state shared by DataTable and DataTablePaginated
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ import * as React from "react"
8
+ import type { RowHeight } from "@/lib/row-height"
9
+ import type { ColumnDef, SortDir } from "./types"
10
+ import type { ActiveFilter, FilterOperator, SortRule } from "@/components/table-properties/types"
11
+ import { parseRowDateToYmd } from "@/lib/date-filter"
12
+
13
+ let _filterId = 0
14
+ function nextFilterId() { return `f-${++_filterId}` }
15
+
16
+ /**
17
+ * “Reflow” / high-zoom short viewport. At 200% zoom a 1080p monitor’s CSS
18
+ * height is ≈ 540px — `500px` was too low and never disabled pins. 640px
19
+ * catches typical 200% cases and small laptop tops without breaking `500px` flows.
20
+ * Column stickies + edge shadows harm reflow (WCAG 1.4.10).
21
+ */
22
+ const REFLOW_VIEWPORT_MQ = "(max-height: 640px)"
23
+
24
+ function subscribeReflowViewport(callback: () => void) {
25
+ if (typeof window === "undefined") return () => {}
26
+ const mql = window.matchMedia(REFLOW_VIEWPORT_MQ)
27
+ mql.addEventListener("change", callback)
28
+ return () => mql.removeEventListener("change", callback)
29
+ }
30
+ function getReflowViewportSnapshot() {
31
+ if (typeof window === "undefined") return false
32
+ return window.matchMedia(REFLOW_VIEWPORT_MQ).matches
33
+ }
34
+ function getServerReflowViewportSnapshot() {
35
+ return false
36
+ }
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Helpers
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ function digitsOnly(s: string): string {
43
+ return s.replace(/\D/g, "")
44
+ }
45
+
46
+ /** Build the default widths map from column defs */
47
+ function buildDefaultWidths<TData>(columns: ColumnDef<TData>[]): Record<string, number> {
48
+ const map: Record<string, number> = {}
49
+ for (const col of columns) {
50
+ if (col.width !== undefined) map[col.key] = col.width
51
+ }
52
+ return map
53
+ }
54
+
55
+ /** Build the initial pin state from column defs */
56
+ function buildDefaultPins<TData>(columns: ColumnDef<TData>[]): Record<string, "left" | "right"> {
57
+ const map: Record<string, "left" | "right"> = {}
58
+ for (const col of columns) {
59
+ if (col.defaultPin) map[col.key] = col.defaultPin
60
+ }
61
+ return map
62
+ }
63
+
64
+ function compareUnknownSort(a: unknown, b: unknown): number {
65
+ if (a === b) return 0
66
+ if (a == null && b == null) return 0
67
+ if (a == null) return 1
68
+ if (b == null) return -1
69
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0
70
+ if (typeof a === "string" && typeof b === "string") return a < b ? -1 : a > b ? 1 : 0
71
+ const as = String(a)
72
+ const bs = String(b)
73
+ return as < bs ? -1 : as > bs ? 1 : 0
74
+ }
75
+
76
+ /** Build the locked-pin set (columns that can never be unpinned) */
77
+ function buildLockedPins<TData>(columns: ColumnDef<TData>[]): Record<string, "left" | "right"> {
78
+ const map: Record<string, "left" | "right"> = {}
79
+ for (const col of columns) {
80
+ if (col.lockPin && col.defaultPin) map[col.key] = col.defaultPin
81
+ }
82
+ return map
83
+ }
84
+
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ // Hook
87
+ // ─────────────────────────────────────────────────────────────────────────────
88
+
89
+ export function useTableState<TData extends Record<string, unknown>>(
90
+ data: TData[],
91
+ columns: ColumnDef<TData>[],
92
+ defaultSort?: { key: string; dir: SortDir },
93
+ paginationOverride?: { page: number; pageSize: number },
94
+ ) {
95
+ // ── Sort ──────────────────────────────────────────────────────────────────
96
+ const [sortRules, setSortRules] = React.useState<SortRule[]>(() => {
97
+ if (defaultSort) {
98
+ return [{ id: "sort-default", fieldKey: defaultSort.key, direction: defaultSort.dir }]
99
+ }
100
+ return []
101
+ })
102
+
103
+ const primarySort = sortRules[0] ?? null
104
+ const sortKey: string = primarySort?.fieldKey ?? ""
105
+ const sortDir: SortDir = primarySort?.direction ?? "asc"
106
+
107
+ const addSortRule = React.useCallback((fieldKey: string) => {
108
+ setSortRules(prev => {
109
+ if (prev.some(r => r.fieldKey === fieldKey)) return prev
110
+ return [...prev, { id: `sort-${Date.now()}`, fieldKey, direction: "asc" }]
111
+ })
112
+ }, [setSortRules])
113
+
114
+ const removeSortRule = React.useCallback((id: string) => {
115
+ setSortRules(prev => prev.filter(r => r.id !== id))
116
+ }, [setSortRules])
117
+
118
+ const toggleSortDir = React.useCallback((id: string) => {
119
+ setSortRules(prev => prev.map(r =>
120
+ r.id === id ? { ...r, direction: r.direction === "asc" ? "desc" : "asc" } : r
121
+ ))
122
+ }, [setSortRules])
123
+
124
+ const handleSortByKey = React.useCallback((colKey: string) => {
125
+ setSortRules(prev => {
126
+ const idx = prev.findIndex(r => r.fieldKey === colKey)
127
+ if (idx === 0) {
128
+ return prev.map((r, i) => i === 0 ? { ...r, direction: r.direction === "asc" ? "desc" : "asc" } : r)
129
+ }
130
+ const filtered = prev.filter(r => r.fieldKey !== colKey)
131
+ return [{ id: `sort-${Date.now()}`, fieldKey: colKey, direction: "asc" }, ...filtered]
132
+ })
133
+ }, [setSortRules])
134
+
135
+ // ── Filters ───────────────────────────────────────────────────────────────
136
+ const [search, setSearch] = React.useState("")
137
+ const [searchOpen, setSearchOpen] = React.useState(false)
138
+ const searchRef = React.useRef<HTMLInputElement>(null)
139
+ const [activeFilters, setActiveFilters] = React.useState<ActiveFilter[]>([])
140
+ const [filterConnectors, setFilterConnectors] = React.useState<Record<string, "and" | "or">>({})
141
+ const [openFilterId, setOpenFilterId] = React.useState<string | null>(null)
142
+ const [filterBarVisible, setFilterBarVisible] = React.useState(true)
143
+ const [drawerExpandedFilters, setDrawerExpandedFilters] = React.useState<Set<string>>(new Set())
144
+
145
+ const toggleConnector = React.useCallback((leftId: string) => {
146
+ setFilterConnectors(prev => ({ ...prev, [leftId]: prev[leftId] === "or" ? "and" : "or" }))
147
+ }, [setFilterConnectors])
148
+
149
+ function getConnector(leftId: string): "and" | "or" {
150
+ return filterConnectors[leftId] ?? "and"
151
+ }
152
+
153
+ const addFilter = React.useCallback((fieldKey: string, fromDrawer = false) => {
154
+ const col = columns.find(c => c.key === fieldKey)
155
+ if (!col?.filter) return
156
+ const id = nextFilterId()
157
+ const f = col.filter
158
+ const firstOperator: FilterOperator = (() => {
159
+ if (f.type === "select" || f.type === "date") {
160
+ const pick = f.operators?.find(o => o === "is" || o === "is_not")
161
+ return pick ?? "is"
162
+ }
163
+ return f.operators?.[0] ?? "contains"
164
+ })()
165
+ setActiveFilters(prev => [...prev, { id, fieldKey, operator: firstOperator, values: [] }])
166
+ if (fromDrawer) {
167
+ setDrawerExpandedFilters(new Set([id]))
168
+ } else {
169
+ setOpenFilterId(id)
170
+ setFilterBarVisible(true)
171
+ }
172
+ }, [columns, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setFilterBarVisible])
173
+
174
+ const updateFilter = React.useCallback((id: string, patch: Partial<ActiveFilter>) => {
175
+ setActiveFilters(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f))
176
+ }, [setActiveFilters])
177
+
178
+ const removeFilter = React.useCallback((id: string) => {
179
+ // Use functional updates only — no stale-closure risk on activeFilters.
180
+ setActiveFilters(prev => {
181
+ const idx = prev.findIndex(f => f.id === id)
182
+ const next = prev.filter(f => f.id !== id)
183
+ setFilterConnectors(prevC => {
184
+ const c = { ...prevC }
185
+ if (idx > 0 && next.length > 0) {
186
+ const leftId = prev[idx - 1].id
187
+ c[leftId] = prevC[id] ?? prevC[leftId] ?? "and"
188
+ }
189
+ delete c[id]
190
+ return c
191
+ })
192
+ return next
193
+ })
194
+ setOpenFilterId(prev => prev === id ? null : prev)
195
+ }, [setActiveFilters, setFilterConnectors, setOpenFilterId])
196
+
197
+ // ── Group by ──────────────────────────────────────────────────────────────
198
+ const [groupBy, setGroupBy] = React.useState<string | null>(null)
199
+
200
+ // ── Per-column quick-search ───────────────────────────────────────────────
201
+ const [colMenuSearch, setColMenuSearch] = React.useState<Record<string, string>>({})
202
+
203
+ // ── Selection ─────────────────────────────────────────────────────────────
204
+ const [selected, setSelected] = React.useState<Set<string | number>>(new Set())
205
+
206
+ // ── Column widths ─────────────────────────────────────────────────────────
207
+ const [colWidths, setColWidths] = React.useState<Record<string, number>>(() => buildDefaultWidths(columns))
208
+ const resizeRef = React.useRef<{ key: string; startX: number; startW: number } | null>(null)
209
+
210
+ // ── Column order ──────────────────────────────────────────────────────────
211
+ const [colOrder, setColOrder] = React.useState<string[]>(() => columns.map(c => c.key))
212
+
213
+ // ── Column pins ───────────────────────────────────────────────────────────
214
+ const [colPins, setColPins] = React.useState<Record<string, "left" | "right">>(() => buildDefaultPins(columns))
215
+ const lockedPins = React.useMemo(() => buildLockedPins(columns), [columns])
216
+
217
+ // ── Column wrap ───────────────────────────────────────────────────────────
218
+ const [colWrap, setColWrap] = React.useState<Record<string, boolean>>({})
219
+
220
+ // ── Drawer / display settings ─────────────────────────────────────────────
221
+ const [sheetOpen, setSheetOpen] = React.useState(false)
222
+ const [showGridlines, setShowGridlines] = React.useState(true)
223
+ const [rowHeight, setRowHeight] = React.useState<RowHeight>("default")
224
+ const [hiddenCols, setHiddenCols] = React.useState<Set<string>>(new Set())
225
+
226
+ const toggleColVisibility = React.useCallback((key: string) => {
227
+ setHiddenCols(prev => {
228
+ const next = new Set(prev)
229
+ next.has(key) ? next.delete(key) : next.add(key)
230
+ return next
231
+ })
232
+ }, [setHiddenCols])
233
+
234
+ const moveCol = React.useCallback((key: string, dir: "up" | "down") => {
235
+ setColOrder(prev => {
236
+ const lockedLeft = columns.filter(c => c.lockPin && c.defaultPin === "left").map(c => c.key)
237
+ const lockedRight = columns.filter(c => c.lockPin && c.defaultPin === "right").map(c => c.key)
238
+ const orderable = prev.filter(k => !lockedLeft.includes(k) && !lockedRight.includes(k))
239
+ const idx = orderable.indexOf(key)
240
+ if (dir === "up" && idx <= 0) return prev
241
+ if (dir === "down" && idx >= orderable.length - 1) return prev
242
+ const next = [...orderable]
243
+ const swap = dir === "up" ? idx - 1 : idx + 1
244
+ ;[next[idx], next[swap]] = [next[swap], next[idx]]
245
+ return [...lockedLeft, ...next, ...lockedRight]
246
+ })
247
+ }, [columns, setColOrder])
248
+
249
+ // ── Drag-to-reorder ───────────────────────────────────────────────────────
250
+ const draggedKey = React.useRef<string | null>(null)
251
+ const [dragOverKey, setDragOverKey] = React.useState<string | null>(null)
252
+
253
+ // ── Scroll / overflow ─────────────────────────────────────────────────────
254
+ const scrollRef = React.useRef<HTMLDivElement>(null)
255
+ const [scrolled, setScrolled] = React.useState(false)
256
+ const [scrollEnd, setScrollEnd] = React.useState(false)
257
+ const [isOverflowing, setIsOverflowing] = React.useState(false)
258
+
259
+ const isReflowViewport = React.useSyncExternalStore(
260
+ subscribeReflowViewport,
261
+ getReflowViewportSnapshot,
262
+ getServerReflowViewportSnapshot,
263
+ )
264
+
265
+ // ── Hovered row ───────────────────────────────────────────────────────────
266
+ const [hoveredRow, setHoveredRow] = React.useState<string | number | null>(null)
267
+
268
+ // ── Derived: filtered + sorted rows ──────────────────────────────────────
269
+ const rows = React.useMemo(() => {
270
+ let result = data.slice()
271
+
272
+ if (search.trim()) {
273
+ const q = search.toLowerCase()
274
+ result = result.filter(r =>
275
+ Object.values(r).some(v => String(v ?? "").toLowerCase().includes(q))
276
+ )
277
+ }
278
+
279
+ const activeWithValues = activeFilters.filter(f => f.values.length > 0)
280
+ if (activeWithValues.length > 0) {
281
+ const matchesFilter = (r: TData, filter: ActiveFilter) => {
282
+ const col = columns.find(c => c.key === filter.fieldKey)
283
+ if (!col?.filter) return true
284
+ const rowVal = String(r[filter.fieldKey] ?? "")
285
+ if (col.filter.type === "select") {
286
+ return filter.operator === "is"
287
+ ? filter.values.includes(rowVal)
288
+ : !filter.values.includes(rowVal)
289
+ }
290
+ if (col.filter.type === "date") {
291
+ const targetYmd = filter.values[0]
292
+ if (!targetYmd) return true
293
+ const rowYmd = parseRowDateToYmd(rowVal)
294
+ const op = filter.operator === "is_not" ? "is_not" : "is"
295
+ if (rowYmd === null) return op === "is_not"
296
+ return op === "is" ? rowYmd === targetYmd : rowYmd !== targetYmd
297
+ } else {
298
+ const raw = filter.values[0] ?? ""
299
+ const textMask = col.filter.textMask
300
+ if (textMask === "phone" || textMask === "zip") {
301
+ const q = digitsOnly(raw)
302
+ if (!q) return true
303
+ const hay = digitsOnly(rowVal)
304
+ return filter.operator === "contains"
305
+ ? hay.includes(q)
306
+ : !hay.includes(q)
307
+ }
308
+ const q = raw.toLowerCase()
309
+ if (!q) return true
310
+ return filter.operator === "contains"
311
+ ? rowVal.toLowerCase().includes(q)
312
+ : !rowVal.toLowerCase().includes(q)
313
+ }
314
+ }
315
+ result = result.filter(r => {
316
+ let res = matchesFilter(r, activeWithValues[0])
317
+ for (let i = 1; i < activeWithValues.length; i++) {
318
+ const connector = getConnector(activeWithValues[i - 1].id)
319
+ const match = matchesFilter(r, activeWithValues[i])
320
+ res = connector === "and" ? res && match : res || match
321
+ }
322
+ return res
323
+ })
324
+ }
325
+
326
+ // Column menu quick-search
327
+ Object.entries(colMenuSearch).forEach(([key, q]) => {
328
+ if (!q.trim()) return
329
+ const lower = q.toLowerCase()
330
+ result = result.filter(r => String(r[key] ?? "").toLowerCase().includes(lower))
331
+ })
332
+
333
+ // Sort
334
+ if (sortRules.length > 0) {
335
+ result.sort((a, b) => {
336
+ for (const rule of sortRules) {
337
+ const col = columns.find(c => c.key === rule.fieldKey)
338
+ const sk = col?.sortKey ?? col?.key
339
+ if (!sk) continue
340
+ const aVal = a[sk as string]
341
+ const bVal = b[sk as string]
342
+ const cmp = compareUnknownSort(aVal, bVal)
343
+ if (cmp !== 0) return rule.direction === "asc" ? cmp : -cmp
344
+ }
345
+ return 0
346
+ })
347
+ }
348
+
349
+ return result
350
+ // eslint-disable-next-line react-hooks/exhaustive-deps
351
+ }, [data, search, activeFilters, filterConnectors, colMenuSearch, sortRules, columns])
352
+
353
+ // ── Paged rows (slice of rows when pagination is active) ─────────────────
354
+ const pagedRows = React.useMemo(() => {
355
+ if (!paginationOverride || paginationOverride.pageSize <= 0) return rows
356
+ const { page, pageSize } = paginationOverride
357
+ const safePage = Math.max(1, page)
358
+ return rows.slice((safePage - 1) * pageSize, safePage * pageSize)
359
+ // eslint-disable-next-line react-hooks/exhaustive-deps
360
+ }, [rows, paginationOverride?.page, paginationOverride?.pageSize])
361
+
362
+ // ── Grouped rows ──────────────────────────────────────────────────────────
363
+ const groupedRows = React.useMemo(() => {
364
+ if (!groupBy) return [{ groupKey: null as string | null, groupLabel: null as string | null, rows }]
365
+ const groups = new Map<string, TData[]>()
366
+ rows.forEach(row => {
367
+ const val = String(row[groupBy] ?? "—")
368
+ if (!groups.has(val)) groups.set(val, [])
369
+ groups.get(val)!.push(row)
370
+ })
371
+ return [...groups.entries()]
372
+ .sort(([a], [b]) => a.localeCompare(b))
373
+ .map(([key, groupRows]) => ({ groupKey: key, groupLabel: key, rows: groupRows }))
374
+ }, [rows, groupBy])
375
+
376
+ // ── Effective pins (respect overflow) ─────────────────────────────────────
377
+ const LOCKED_KEYS = React.useMemo(() => new Set(Object.keys(lockedPins)), [lockedPins])
378
+
379
+ // When the table fits within its container (not overflowing) there is no need
380
+ // to sticky-pin any column — even locked ones. Pins only activate once the
381
+ // user has to scroll horizontally so the selection / action edges stay visible.
382
+ // In reflow viewports (high zoom), disable all column stickies — shadow + sticky
383
+ // fight the short viewport and overlap content.
384
+ const effectivePins = React.useMemo(() => {
385
+ if (isReflowViewport || !isOverflowing) return {}
386
+ const result: Record<string, "left" | "right"> = {}
387
+ for (const [key, pin] of Object.entries(colPins)) {
388
+ result[key] = pin
389
+ }
390
+ return result
391
+ // eslint-disable-next-line react-hooks/exhaustive-deps
392
+ }, [colPins, isOverflowing, isReflowViewport])
393
+
394
+ // ── Display columns ───────────────────────────────────────────────────────
395
+ const displayCols = React.useMemo(() => {
396
+ const leftPinned = colOrder.filter(k => colPins[k] === "left")
397
+ const free = colOrder.filter(k => !colPins[k])
398
+ const rightPinned = colOrder.filter(k => colPins[k] === "right")
399
+ return [...leftPinned, ...free, ...rightPinned]
400
+ .map(k => columns.find(c => c.key === k))
401
+ .filter((c): c is ColumnDef<TData> => !!c)
402
+ .filter(c => !hiddenCols.has(c.key))
403
+ }, [colOrder, colPins, hiddenCols, columns])
404
+
405
+ // ── Column actions ────────────────────────────────────────────────────────
406
+ function startResize(key: string, e: React.MouseEvent) {
407
+ e.preventDefault()
408
+ e.stopPropagation()
409
+ const minW = columns.find(c => c.key === key)?.minWidth ?? 60
410
+ const startW = colWidths[key] ?? (columns.find(c => c.key === key)?.width ?? 100)
411
+ resizeRef.current = { key, startX: e.clientX, startW }
412
+ const onMove = (ev: MouseEvent) => {
413
+ if (!resizeRef.current) return
414
+ const { key: k, startX, startW: sw } = resizeRef.current
415
+ setColWidths(p => ({ ...p, [k]: Math.max(minW, sw + ev.clientX - startX) }))
416
+ }
417
+ const onUp = () => {
418
+ resizeRef.current = null
419
+ document.removeEventListener("mousemove", onMove)
420
+ document.removeEventListener("mouseup", onUp)
421
+ }
422
+ document.addEventListener("mousemove", onMove)
423
+ document.addEventListener("mouseup", onUp)
424
+ }
425
+
426
+ function handleDragStart(key: string, e: React.DragEvent<HTMLTableCellElement>) {
427
+ draggedKey.current = key
428
+ e.dataTransfer.effectAllowed = "move"
429
+ }
430
+ function handleDragOver(key: string, e: React.DragEvent<HTMLTableCellElement>) {
431
+ e.preventDefault()
432
+ e.dataTransfer.dropEffect = "move"
433
+ if (draggedKey.current && draggedKey.current !== key) setDragOverKey(key)
434
+ }
435
+ function handleDrop(key: string) {
436
+ if (!draggedKey.current || draggedKey.current === key) { setDragOverKey(null); return }
437
+ const order = [...colOrder]
438
+ const from = order.indexOf(draggedKey.current)
439
+ const to = order.indexOf(key)
440
+ order.splice(from, 1)
441
+ order.splice(to, 0, draggedKey.current!)
442
+ setColOrder(order)
443
+ draggedKey.current = null
444
+ setDragOverKey(null)
445
+ }
446
+ function handleDragEnd() { draggedKey.current = null; setDragOverKey(null) }
447
+
448
+ function pinColumn(key: string, pin: "left" | "right") {
449
+ setColPins(p => ({ ...p, [key]: pin }))
450
+ }
451
+ function unpinColumn(key: string) {
452
+ if (lockedPins[key]) return
453
+ setColPins(p => { const n = { ...p }; delete n[key]; return n })
454
+ }
455
+ function toggleWrap(key: string) {
456
+ setColWrap(p => ({ ...p, [key]: !p[key] }))
457
+ }
458
+
459
+ // ── Scroll handlers ───────────────────────────────────────────────────────
460
+ function checkOverflow() {
461
+ const el = scrollRef.current
462
+ if (!el) return
463
+ setIsOverflowing(el.scrollWidth > el.clientWidth + 1)
464
+ }
465
+ function handleScroll() {
466
+ const el = scrollRef.current
467
+ if (!el) return
468
+ setScrolled(el.scrollLeft > 1)
469
+ setScrollEnd(el.scrollLeft >= el.scrollWidth - el.clientWidth - 1)
470
+ setIsOverflowing(el.scrollWidth > el.clientWidth + 1)
471
+ }
472
+
473
+ // ── Selection helpers ─────────────────────────────────────────────────────
474
+ function getRowId(row: TData, index: number, getIdFn?: (r: TData, i: number) => string | number): string | number {
475
+ return getIdFn ? getIdFn(row, index) : (row.id as string | number ?? index)
476
+ }
477
+
478
+ const toggleRow = React.useCallback((id: string | number) => {
479
+ setSelected(prev => {
480
+ const next = new Set(prev)
481
+ next.has(id) ? next.delete(id) : next.add(id)
482
+ return next
483
+ })
484
+ }, [setSelected])
485
+
486
+ const toggleAll = React.useCallback((allRowIds: (string | number)[]) => {
487
+ setSelected(prev => prev.size === allRowIds.length ? new Set() : new Set(allRowIds))
488
+ }, [setSelected])
489
+
490
+ // ── Sticky offset calculations ────────────────────────────────────────────
491
+ function getStickyLeft(key: string): number {
492
+ let offset = 0
493
+ for (const col of displayCols) {
494
+ if (effectivePins[col.key] !== "left") break
495
+ if (col.key === key) return offset
496
+ offset += colWidths[col.key] ?? col.width ?? 100
497
+ }
498
+ return 0
499
+ }
500
+ function getStickyRight(key: string): number {
501
+ let offset = 0
502
+ const rightCols = [...displayCols].filter(c => effectivePins[c.key] === "right").reverse()
503
+ for (const col of rightCols) {
504
+ if (col.key === key) return offset
505
+ offset += colWidths[col.key] ?? col.width ?? 100
506
+ }
507
+ return 0
508
+ }
509
+ function stickyStyle(key: string, isHeader = false): React.CSSProperties {
510
+ if (isReflowViewport) return {}
511
+ const pin = effectivePins[key]
512
+ if (pin === "left") return { position: "sticky", left: getStickyLeft(key), ...(isHeader ? { top: 0 } : {}) }
513
+ if (pin === "right") return { position: "sticky", right: getStickyRight(key), ...(isHeader ? { top: 0 } : {}) }
514
+ return isHeader ? { position: "sticky", top: 0 } : {}
515
+ }
516
+
517
+ const totalWidth = displayCols.reduce((s, c) => s + (colWidths[c.key] ?? c.width ?? 100), 0)
518
+
519
+ return {
520
+ // Sort
521
+ sortRules, setSortRules,
522
+ sortKey, sortDir,
523
+ addSortRule, removeSortRule, toggleSortDir, handleSortByKey,
524
+ // Filters
525
+ search, setSearch,
526
+ searchOpen, setSearchOpen,
527
+ searchRef,
528
+ activeFilters, setActiveFilters,
529
+ filterConnectors, setFilterConnectors, toggleConnector, getConnector,
530
+ openFilterId, setOpenFilterId,
531
+ filterBarVisible, setFilterBarVisible,
532
+ drawerExpandedFilters, setDrawerExpandedFilters,
533
+ addFilter, updateFilter, removeFilter,
534
+ // Group
535
+ groupBy, setGroupBy,
536
+ // Column quick-search
537
+ colMenuSearch, setColMenuSearch,
538
+ // Selection
539
+ selected, setSelected, toggleRow, toggleAll, getRowId,
540
+ // Column widths / order / pins / wrap
541
+ colWidths, setColWidths, resizeRef, startResize,
542
+ colOrder, setColOrder, moveCol,
543
+ colPins, setColPins, lockedPins, LOCKED_KEYS,
544
+ pinColumn, unpinColumn,
545
+ colWrap, setColWrap, toggleWrap,
546
+ // Drag-to-reorder
547
+ draggedKey, dragOverKey,
548
+ handleDragStart, handleDragOver, handleDrop, handleDragEnd,
549
+ // Scroll
550
+ scrollRef, scrolled, scrollEnd, isOverflowing,
551
+ checkOverflow, handleScroll,
552
+ // Hover
553
+ hoveredRow, setHoveredRow,
554
+ // Derived
555
+ rows, pagedRows, groupedRows,
556
+ effectivePins, displayCols,
557
+ isReflowViewport,
558
+ getStickyLeft, getStickyRight, stickyStyle,
559
+ totalWidth,
560
+ // Display settings
561
+ sheetOpen, setSheetOpen,
562
+ showGridlines, setShowGridlines,
563
+ rowHeight, setRowHeight,
564
+ hiddenCols, setHiddenCols, toggleColVisibility,
565
+ }
566
+ }