@exxatdesignux/ui 0.0.5 → 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,1102 @@
1
+ "use client"
2
+ import * as React from "react"
3
+ import { cn } from "@/lib/utils"
4
+ import type { DataListViewType } from "@/lib/data-list-view"
5
+ import { DATA_LIST_VIEW_TILES, dataListViewLabel } from "@/lib/data-list-view"
6
+ import type { RowHeight } from "@/lib/row-height"
7
+ import { ROW_HEIGHT_TILES } from "@/lib/row-height"
8
+ import { SelectionTileGrid } from "@/components/ui/selection-tile-grid"
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuLabel,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ } from "@/components/ui/dropdown-menu"
17
+ import {
18
+ Sheet,
19
+ SheetContent,
20
+ SheetTitle,
21
+ } from "@/components/ui/sheet"
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from "@/components/ui/select"
29
+ import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
30
+ import { Tip } from "@/components/ui/tip"
31
+ import { ToggleSwitch } from "@/components/ui/toggle-switch"
32
+ import { Button } from "@/components/ui/button"
33
+ import { DrawerFilterCard } from "./filter-card"
34
+ import { DrawerSortCard } from "./sort-card"
35
+ import { ColumnRow } from "./column-row"
36
+ import { useDraggableList } from "./draggable-list"
37
+ import {
38
+ type ActiveFilter,
39
+ type SortRule,
40
+ type ConditionalRule,
41
+ type FilterFieldDef,
42
+ COLUMNS,
43
+ FILTER_FIELDS,
44
+ RULE_COLORS,
45
+ } from "./types"
46
+
47
+ export interface TablePropertiesDrawerProps {
48
+ open: boolean
49
+ onOpenChange: (open: boolean) => void
50
+ // Display
51
+ showGridlines: boolean
52
+ onShowGridlinesChange: (v: boolean) => void
53
+ rowHeight: RowHeight
54
+ onRowHeightChange: (v: RowHeight) => void
55
+ pagination: boolean
56
+ onPaginationChange: (v: boolean) => void
57
+ // Filters
58
+ activeFilters: ActiveFilter[]
59
+ onAddFilter: (fieldKey: string) => void
60
+ onUpdateFilter: (id: string, patch: Partial<ActiveFilter>) => void
61
+ onRemoveFilter: (id: string) => void
62
+ /** How the filter after `leftFilterId` combines with the one above (default "and"). */
63
+ getFilterConnector: (leftFilterId: string) => "and" | "or"
64
+ onToggleFilterConnector: (leftFilterId: string) => void
65
+ filterBarVisible: boolean
66
+ onFilterBarVisibleChange: (v: boolean) => void
67
+ drawerExpandedFilters: Set<string>
68
+ onDrawerExpandedFiltersChange: React.Dispatch<React.SetStateAction<Set<string>>>
69
+ totalRows: number
70
+ filteredRows: number
71
+ // Sort
72
+ sortRules: SortRule[]
73
+ onSortRulesChange: (rules: SortRule[]) => void
74
+ onAddSortRule: (fieldKey: string) => void
75
+ onRemoveSortRule: (id: string) => void
76
+ onToggleSortDir: (id: string) => void
77
+ // Columns
78
+ colOrder: string[]
79
+ onColOrderChange: (order: string[]) => void
80
+ hiddenCols: Set<string>
81
+ onToggleColVisibility: (key: string) => void
82
+ onMoveCol: (key: string, dir: "up" | "down") => void
83
+ // Group
84
+ groupBy: string | null
85
+ onGroupByChange: (key: string | null) => void
86
+ // Sort key for display in main panel
87
+ primarySortKey?: string
88
+ // Conditional formatting
89
+ conditionalRules: ConditionalRule[]
90
+ onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
91
+ onRemoveConditionalRule: (id: string) => void
92
+ onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
93
+ /** Filter field defs for drawer + conditional rules — defaults to FILTER_FIELDS; pass column-derived defs to match the table */
94
+ filterFields?: FilterFieldDef[]
95
+ // View type
96
+ currentView?: DataListViewType
97
+ onViewChange?: (view: DataListViewType) => void
98
+ /** Lifecycle context (e.g. tab filter) — shown in the drawer header */
99
+ lifecycleTabLabel?: string
100
+ /**
101
+ * Column labels for the active table definition (placements use dynamic columns per tab).
102
+ * When set, overrides static `COLUMNS` from types for Columns / Sort / Group labels.
103
+ */
104
+ fieldDefinitions?: { key: string; label: string; sortable?: boolean }[]
105
+ resolveColumnLabel?: (key: string) => string
106
+ /** Shared display options (table + board); persisted at page level. */
107
+ displayOptions: DataListDisplayOptions
108
+ onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
109
+ /**
110
+ * When the active view is Board and more than one entry is provided, shows a control to pick
111
+ * which field defines swimlane columns (`displayOptions.boardGroupByColumnKey`).
112
+ */
113
+ boardGroupByColumnOptions?: { key: string; label: string }[]
114
+ /** Optional custom option renderer for filter values (e.g. status chips). */
115
+ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode
116
+ }
117
+
118
+ type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
119
+
120
+ export function TablePropertiesDrawer({
121
+ open,
122
+ onOpenChange,
123
+ showGridlines,
124
+ onShowGridlinesChange,
125
+ rowHeight,
126
+ onRowHeightChange,
127
+ pagination,
128
+ onPaginationChange,
129
+ activeFilters,
130
+ onAddFilter,
131
+ onUpdateFilter,
132
+ onRemoveFilter,
133
+ getFilterConnector,
134
+ onToggleFilterConnector,
135
+ filterBarVisible,
136
+ onFilterBarVisibleChange,
137
+ drawerExpandedFilters,
138
+ onDrawerExpandedFiltersChange,
139
+ totalRows,
140
+ filteredRows,
141
+ sortRules,
142
+ onSortRulesChange,
143
+ onAddSortRule,
144
+ onRemoveSortRule,
145
+ onToggleSortDir,
146
+ colOrder,
147
+ onColOrderChange,
148
+ hiddenCols,
149
+ onToggleColVisibility,
150
+ onMoveCol,
151
+ groupBy,
152
+ onGroupByChange,
153
+ primarySortKey,
154
+ conditionalRules,
155
+ onAddConditionalRule,
156
+ onRemoveConditionalRule,
157
+ onUpdateConditionalRule,
158
+ filterFields = FILTER_FIELDS,
159
+ currentView,
160
+ onViewChange,
161
+ lifecycleTabLabel,
162
+ fieldDefinitions,
163
+ resolveColumnLabel: resolveColumnLabelProp,
164
+ displayOptions,
165
+ onDisplayOptionsChange,
166
+ boardGroupByColumnOptions,
167
+ renderFilterOptionValue,
168
+ }: TablePropertiesDrawerProps) {
169
+ const [sheetPanel, setSheetPanel] = React.useState<SheetPanel>("main")
170
+
171
+ // Reset to main panel when drawer is closed
172
+ React.useEffect(() => {
173
+ if (!open) setSheetPanel("main")
174
+ }, [open])
175
+
176
+ const resolveColumnLabel = React.useCallback(
177
+ (key: string) =>
178
+ resolveColumnLabelProp?.(key)
179
+ ?? COLUMNS.find(c => c.key === key)?.label
180
+ ?? key,
181
+ [resolveColumnLabelProp],
182
+ )
183
+
184
+ const sortFieldList = React.useMemo(() => {
185
+ if (fieldDefinitions?.length) {
186
+ return fieldDefinitions.filter(f => f.sortable !== false && f.key !== "select" && f.key !== "actions")
187
+ }
188
+ return COLUMNS.filter(c => c.sortable && c.sortKey).map(c => ({ key: c.key, label: c.label, sortable: true }))
189
+ }, [fieldDefinitions])
190
+
191
+ const groupFieldList = React.useMemo(() => {
192
+ if (fieldDefinitions?.length) {
193
+ return fieldDefinitions.filter(f => f.key !== "select" && f.key !== "actions")
194
+ }
195
+ return COLUMNS.filter(c => c.key !== "select" && c.key !== "actions")
196
+ }, [fieldDefinitions])
197
+
198
+ const viewSurface = currentView ?? "table"
199
+ const isBoardView = viewSurface === "board"
200
+ const boardGroupByLabel =
201
+ boardGroupByColumnOptions?.find(o => o.key === displayOptions.boardGroupByColumnKey)?.label
202
+ const viewDisplayLabel = dataListViewLabel(viewSurface)
203
+ const viewDisplayDesc = (() => {
204
+ if (viewSurface === "board") {
205
+ return [
206
+ boardGroupByLabel ? `By ${boardGroupByLabel}` : null,
207
+ `${displayOptions.boardLineCount}-line`,
208
+ displayOptions.showColumnLabels ? "Column labels" : "No labels",
209
+ ]
210
+ .filter(Boolean)
211
+ .join(" · ")
212
+ }
213
+ if (viewSurface === "list") {
214
+ return [
215
+ displayOptions.showColumnLabels ? "Column labels" : "No labels",
216
+ displayOptions.showToolbarSearch ? "Toolbar search" : "No search",
217
+ ].join(" · ")
218
+ }
219
+ if (viewSurface === "dashboard") {
220
+ return "Charts · KPI metrics"
221
+ }
222
+ return [showGridlines ? "Gridlines" : null, pagination ? "Paginated" : null].filter(Boolean).join(" · ") || "Default"
223
+ })()
224
+ const viewDisplayIcon =
225
+ DATA_LIST_VIEW_TILES.find(t => t.value === viewSurface)?.icon ?? "fa-table"
226
+
227
+ // ── Sort drag-and-drop ────────────────────────────────────────────────────
228
+ const sortDrag = useDraggableList(sortRules, r => r.id, onSortRulesChange)
229
+
230
+ // ── Columns drag-and-drop ─────────────────────────────────────────────────
231
+ const orderable = colOrder.filter(k => k !== "select" && k !== "actions")
232
+ const colDrag = useDraggableList(
233
+ orderable,
234
+ k => k,
235
+ newOrder => onColOrderChange(["select", ...newOrder, "actions"]),
236
+ )
237
+
238
+ // Current primary sort label for display in main panel
239
+ const primarySortLabel = primarySortKey
240
+ ? resolveColumnLabel(primarySortKey)
241
+ : sortRules[0]?.fieldKey
242
+ ? resolveColumnLabel(sortRules[0].fieldKey)
243
+ : "—"
244
+
245
+ return (
246
+ <Sheet open={open} onOpenChange={onOpenChange}>
247
+ <SheetContent
248
+ side="right"
249
+ showCloseButton={false}
250
+ showOverlay={false}
251
+ // w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
252
+ // so the drawer never overflows horizontally. Use 100svh so height is correct on mobile.
253
+ className="w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
254
+ style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
255
+ >
256
+
257
+ {sheetPanel === "main" ? (
258
+ <>
259
+ {/* Header */}
260
+ <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-3">
261
+ <div className="min-w-0">
262
+ <SheetTitle className="text-base font-semibold leading-tight">Properties</SheetTitle>
263
+ {lifecycleTabLabel ? (
264
+ <p className="text-xs text-muted-foreground mt-0.5 truncate" title={lifecycleTabLabel}>
265
+ {lifecycleTabLabel}
266
+ </p>
267
+ ) : null}
268
+ </div>
269
+ <Tip label="Close" side="bottom">
270
+ <Button
271
+ type="button"
272
+ variant="ghost"
273
+ size="icon-sm"
274
+ aria-label="Close"
275
+ onClick={() => onOpenChange(false)}
276
+ >
277
+ <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
278
+ </Button>
279
+ </Tip>
280
+ </div>
281
+
282
+ {/* View type switcher — card tiles like export file format */}
283
+ {onViewChange && currentView && (
284
+ <div className="px-4 pb-3">
285
+ <SelectionTileGrid<DataListViewType>
286
+ sectionLabel="View type"
287
+ options={DATA_LIST_VIEW_TILES}
288
+ columns={4}
289
+ value={currentView}
290
+ onValueChange={onViewChange}
291
+ interaction="button"
292
+ idPrefix="props-view"
293
+ />
294
+ </div>
295
+ )}
296
+
297
+ {/* Option list — inset rows + rounded hover (not edge-to-edge) */}
298
+ <div className="flex-1 overflow-y-auto py-2 px-3 space-y-1">
299
+ {([
300
+ {
301
+ id: "table-display" as SheetPanel,
302
+ icon: viewDisplayIcon,
303
+ label: viewDisplayLabel,
304
+ desc: viewDisplayDesc,
305
+ },
306
+ {
307
+ id: "filter" as SheetPanel,
308
+ icon: "fa-filter",
309
+ label: "Filter",
310
+ desc: activeFilters.length === 0
311
+ ? `Showing all ${filteredRows} rows.`
312
+ : `${activeFilters.length} filter${activeFilters.length !== 1 ? "s" : ""} active · ${filteredRows} rows.`,
313
+ },
314
+ {
315
+ id: "sort" as SheetPanel,
316
+ icon: "fa-arrow-up-arrow-down",
317
+ label: "Sort",
318
+ desc: `Sorted by ${primarySortLabel}.`,
319
+ },
320
+ {
321
+ id: "group" as SheetPanel,
322
+ icon: "fa-layer-group",
323
+ label: "Group",
324
+ desc: groupBy
325
+ ? `Grouped by ${resolveColumnLabel(groupBy)}.`
326
+ : "No grouping.",
327
+ },
328
+ {
329
+ id: "columns" as SheetPanel,
330
+ icon: "fa-table-columns",
331
+ label: "Columns",
332
+ desc: hiddenCols.size === 0
333
+ ? "All columns visible."
334
+ : `${hiddenCols.size} column${hiddenCols.size !== 1 ? "s" : ""} hidden.`,
335
+ },
336
+ {
337
+ id: "conditional-rules" as SheetPanel,
338
+ icon: "fa-palette",
339
+ label: "Conditional rules",
340
+ desc: conditionalRules.length === 0
341
+ ? "No rules applied."
342
+ : `${conditionalRules.length} rule${conditionalRules.length !== 1 ? "s" : ""} active.`,
343
+ },
344
+ ] as { id: SheetPanel; icon: string; label: string; desc: string }[]).map(item => (
345
+ <Button
346
+ key={item.id}
347
+ type="button"
348
+ variant="ghost"
349
+ onClick={() => setSheetPanel(item.id)}
350
+ className={cn(
351
+ "w-full h-auto justify-start gap-3 px-3 py-3 rounded-2xl font-normal border border-transparent",
352
+ "hover:bg-muted/60 hover:text-foreground",
353
+ "focus-visible:bg-muted/60 focus-visible:text-foreground",
354
+ )}
355
+ >
356
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
357
+ <i className={`fa-light ${item.icon} text-[15px] text-secondary-foreground`} aria-hidden="true" />
358
+ </span>
359
+ <span className="flex-1 min-w-0 text-left">
360
+ <span className="block text-sm font-medium text-foreground">{item.label}</span>
361
+ <span className="block text-xs text-muted-foreground mt-0.5">{item.desc}</span>
362
+ </span>
363
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground shrink-0" aria-hidden="true" />
364
+ </Button>
365
+ ))}
366
+ </div>
367
+ </>
368
+ ) : (
369
+ <>
370
+ {/* Sub-panel header — back + title stack as one cluster; close aligns to row center */}
371
+ <div className="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
372
+ <div className="flex items-center gap-2 min-w-0 flex-1">
373
+ <Tip label="Back to Properties" side="bottom">
374
+ <Button
375
+ type="button"
376
+ variant="ghost"
377
+ size="icon-sm"
378
+ className="shrink-0"
379
+ aria-label="Back to Properties"
380
+ onClick={() => setSheetPanel("main")}
381
+ >
382
+ <i className="fa-light fa-chevron-left text-[13px]" aria-hidden="true" />
383
+ </Button>
384
+ </Tip>
385
+ <div className="min-w-0">
386
+ <SheetTitle className="text-base font-semibold text-foreground leading-tight flex items-center gap-1.5">
387
+ {{
388
+ "table-display": viewDisplayLabel,
389
+ filter: "Filter",
390
+ sort: "Sort",
391
+ group: "Group",
392
+ columns: "Columns",
393
+ "conditional-rules": "Conditional rules",
394
+ main: "",
395
+ }[sheetPanel]}
396
+ {sheetPanel === "filter" && (
397
+ <i className="fa-light fa-circle-question text-xs text-muted-foreground" aria-hidden="true" />
398
+ )}
399
+ </SheetTitle>
400
+ {sheetPanel === "filter" && (
401
+ <p
402
+ className="text-xs text-muted-foreground mt-0.5"
403
+ aria-live="polite"
404
+ aria-atomic="true"
405
+ >
406
+ {activeFilters.length === 0
407
+ ? `Showing all ${filteredRows} rows`
408
+ : `${filteredRows} of ${totalRows} rows match · ${activeFilters.length} filter${activeFilters.length !== 1 ? "s" : ""} active`}
409
+ </p>
410
+ )}
411
+ </div>
412
+ </div>
413
+ <Tip label="Close" side="bottom">
414
+ <Button
415
+ type="button"
416
+ variant="ghost"
417
+ size="icon-sm"
418
+ className="shrink-0"
419
+ aria-label="Close panel"
420
+ onClick={() => onOpenChange(false)}
421
+ >
422
+ <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
423
+ </Button>
424
+ </Tip>
425
+ </div>
426
+
427
+ <div className="flex-1 overflow-y-auto">
428
+
429
+ {/* ── Table / Board display ── */}
430
+ {sheetPanel === "table-display" && (
431
+ <div className="p-4 space-y-5">
432
+ {isBoardView ? (
433
+ <p className="text-xs text-muted-foreground leading-relaxed">
434
+ {dataListViewLabel("board")} groups rows into columns. Sort, filter, and column settings apply to the same dataset as other views (e.g. Table view).
435
+ </p>
436
+ ) : null}
437
+
438
+ {isBoardView && boardGroupByColumnOptions && boardGroupByColumnOptions.length > 1 ? (
439
+ <div className="flex items-center justify-between gap-3 py-2">
440
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
441
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
442
+ <i className="fa-light fa-table-columns text-[15px] text-secondary-foreground" aria-hidden="true" />
443
+ </span>
444
+ <div className="min-w-0">
445
+ <p className="text-sm font-medium text-foreground leading-tight">Board columns</p>
446
+ <p className="text-xs text-muted-foreground mt-0.5">Choose which field splits the board into swimlanes.</p>
447
+ </div>
448
+ </div>
449
+ <Select
450
+ value={
451
+ boardGroupByColumnOptions.some(o => o.key === displayOptions.boardGroupByColumnKey)
452
+ ? displayOptions.boardGroupByColumnKey
453
+ : boardGroupByColumnOptions[0]!.key
454
+ }
455
+ onValueChange={v => onDisplayOptionsChange({ boardGroupByColumnKey: v })}
456
+ >
457
+ <SelectTrigger
458
+ size="sm"
459
+ className="w-[9.5rem] shrink-0"
460
+ id="board-group-by-field"
461
+ aria-label="Field for board columns"
462
+ >
463
+ <SelectValue />
464
+ </SelectTrigger>
465
+ <SelectContent align="end">
466
+ {boardGroupByColumnOptions.map(o => (
467
+ <SelectItem key={o.key} value={o.key}>
468
+ {o.label}
469
+ </SelectItem>
470
+ ))}
471
+ </SelectContent>
472
+ </Select>
473
+ </div>
474
+ ) : null}
475
+
476
+ {viewSurface === "table" ? (
477
+ <>
478
+ <div>
479
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Appearance</p>
480
+ <div className="space-y-1">
481
+ {([
482
+ { id: "gridlines", icon: "fa-border-all", label: "Gridlines", checked: showGridlines, onChange: onShowGridlinesChange },
483
+ { id: "pagination", icon: "fa-table-list", label: "Pagination", checked: pagination, onChange: onPaginationChange },
484
+ ] as { id: string; icon: string; label: string; checked: boolean; onChange: (v: boolean) => void }[]).map(row => (
485
+ <div key={row.id} className="flex items-center justify-between py-2">
486
+ <div className="flex items-center gap-2.5 text-sm">
487
+ <i className={`fa-light ${row.icon} text-muted-foreground w-4 text-center`} aria-hidden="true" />
488
+ <label htmlFor={`toggle-${row.id}`} className="cursor-pointer select-none">{row.label}</label>
489
+ </div>
490
+ <ToggleSwitch id={`toggle-${row.id}`} checked={row.checked} onChange={row.onChange} />
491
+ </div>
492
+ ))}
493
+ </div>
494
+ </div>
495
+
496
+ <div className="border-t border-border pt-4">
497
+ <SelectionTileGrid<RowHeight>
498
+ sectionLabel="Row height"
499
+ options={ROW_HEIGHT_TILES}
500
+ columns={3}
501
+ value={rowHeight}
502
+ onValueChange={onRowHeightChange}
503
+ interaction="button"
504
+ idPrefix="row-height"
505
+ />
506
+ </div>
507
+ </>
508
+ ) : null}
509
+
510
+ <div
511
+ className={cn(
512
+ "space-y-3",
513
+ (viewSurface === "board" || viewSurface === "table") && "border-t border-border pt-4",
514
+ )}
515
+ >
516
+ <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Display options</p>
517
+ <div className="space-y-1">
518
+ {isBoardView && (
519
+ <div className="flex items-center justify-between gap-2 py-2">
520
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
521
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
522
+ <i className="fa-light fa-file-lines text-[15px] text-secondary-foreground" aria-hidden="true" />
523
+ </span>
524
+ <div className="min-w-0">
525
+ <p className="text-sm font-medium text-foreground leading-tight">Line count</p>
526
+ </div>
527
+ </div>
528
+ <Select
529
+ value={String(displayOptions.boardLineCount)}
530
+ onValueChange={v =>
531
+ onDisplayOptionsChange({ boardLineCount: Number(v) as 1 | 2 | 3 })}
532
+ >
533
+ <SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
534
+ <SelectValue />
535
+ </SelectTrigger>
536
+ <SelectContent align="end">
537
+ <SelectItem value="1">1 line</SelectItem>
538
+ <SelectItem value="2">2 lines</SelectItem>
539
+ <SelectItem value="3">3 lines</SelectItem>
540
+ </SelectContent>
541
+ </Select>
542
+ </div>
543
+ )}
544
+
545
+ {viewSurface === "table" && (
546
+ <div className="flex items-center justify-between gap-2 py-2">
547
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
548
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
549
+ <i className="fa-light fa-font text-[15px] text-secondary-foreground" aria-hidden="true" />
550
+ </span>
551
+ <div className="min-w-0">
552
+ <p className="text-sm font-medium text-foreground leading-tight">Table title</p>
553
+ <p className="text-xs text-muted-foreground mt-0.5">Show the page heading and subtitle.</p>
554
+ </div>
555
+ </div>
556
+ <ToggleSwitch
557
+ id="toggle-view-title"
558
+ checked={displayOptions.showViewTitle}
559
+ onChange={v => onDisplayOptionsChange({ showViewTitle: v })}
560
+ />
561
+ </div>
562
+ )}
563
+
564
+ <div className="flex items-center justify-between gap-2 py-2">
565
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
566
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
567
+ <i className="fa-light fa-table-columns text-[15px] text-secondary-foreground" aria-hidden="true" />
568
+ </span>
569
+ <div className="min-w-0">
570
+ <p className="text-sm font-medium text-foreground leading-tight">Column labels</p>
571
+ {viewSurface === "table" ? (
572
+ <p className="text-xs text-muted-foreground mt-0.5">Column headers in the table.</p>
573
+ ) : viewSurface === "list" ? (
574
+ <p className="text-xs text-muted-foreground mt-0.5">Column headers in the list.</p>
575
+ ) : null}
576
+ </div>
577
+ </div>
578
+ <ToggleSwitch
579
+ id="toggle-column-labels"
580
+ checked={displayOptions.showColumnLabels}
581
+ onChange={v => onDisplayOptionsChange({ showColumnLabels: v })}
582
+ />
583
+ </div>
584
+
585
+ {isBoardView && (
586
+ <>
587
+ <div className="flex items-center justify-between gap-2 py-2">
588
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
589
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
590
+ <i className="fa-light fa-hashtag text-[15px] text-secondary-foreground" aria-hidden="true" />
591
+ </span>
592
+ <div className="min-w-0">
593
+ <p className="text-sm font-medium text-foreground leading-tight">Column counts</p>
594
+ </div>
595
+ </div>
596
+ <ToggleSwitch
597
+ id="toggle-board-counts"
598
+ checked={displayOptions.showBoardColumnCounts}
599
+ onChange={v => onDisplayOptionsChange({ showBoardColumnCounts: v })}
600
+ />
601
+ </div>
602
+
603
+ <div className="flex items-center justify-between gap-2 py-2">
604
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
605
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
606
+ <i className="fa-light fa-square-plus text-[15px] text-secondary-foreground" aria-hidden="true" />
607
+ </span>
608
+ <div className="min-w-0">
609
+ <p className="text-sm font-medium text-foreground leading-tight">Above new card button</p>
610
+ </div>
611
+ </div>
612
+ <ToggleSwitch
613
+ id="toggle-new-card-above"
614
+ checked={displayOptions.boardNewCardAbove}
615
+ onChange={v => onDisplayOptionsChange({ boardNewCardAbove: v })}
616
+ />
617
+ </div>
618
+ </>
619
+ )}
620
+
621
+ {(viewSurface === "table" || viewSurface === "list") && (
622
+ <div className="flex items-center justify-between gap-2 py-2">
623
+ <div className="flex items-center gap-2.5 min-w-0 flex-1">
624
+ <span className="inline-flex items-center justify-center size-9 rounded-lg bg-secondary border border-border shrink-0">
625
+ <i className="fa-light fa-magnifying-glass text-[15px] text-secondary-foreground" aria-hidden="true" />
626
+ </span>
627
+ <div className="min-w-0">
628
+ <p className="text-sm font-medium text-foreground leading-tight">Search</p>
629
+ <p className="text-xs text-muted-foreground mt-0.5">Toolbar search for this view.</p>
630
+ </div>
631
+ </div>
632
+ <ToggleSwitch
633
+ id="toggle-toolbar-search"
634
+ checked={displayOptions.showToolbarSearch}
635
+ onChange={v => onDisplayOptionsChange({ showToolbarSearch: v })}
636
+ />
637
+ </div>
638
+ )}
639
+ </div>
640
+ </div>
641
+ </div>
642
+ )}
643
+
644
+ {/* ── Filter ── */}
645
+ {sheetPanel === "filter" && (
646
+ <div className="px-4 py-4 space-y-2">
647
+ {activeFilters.length === 0 ? (
648
+ <div className="rounded-xl border border-border bg-muted/40 p-4 space-y-3">
649
+ <div className="flex items-center gap-2">
650
+ <span className="inline-flex items-center justify-center size-7 rounded-lg bg-background border border-border shrink-0">
651
+ <i className="fa-light fa-filter text-muted-foreground text-xs" aria-hidden="true" />
652
+ </span>
653
+ <p className="text-sm font-medium text-foreground">No filters yet</p>
654
+ </div>
655
+ <p className="text-xs text-muted-foreground leading-relaxed">
656
+ Use filters to show only the rows you need. With multiple filters, use <span className="font-medium text-foreground/80">and</span> or <span className="font-medium text-foreground/80">or</span> between them to control how they combine.
657
+ </p>
658
+ <div className="space-y-1.5">
659
+ {[
660
+ { icon: "fa-circle-1", text: "Click \"Add filter\" below" },
661
+ { icon: "fa-circle-2", text: "Choose a field to filter by" },
662
+ { icon: "fa-circle-3", text: "Pick a condition and value" },
663
+ ].map(step => (
664
+ <div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
665
+ <i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
666
+ {step.text}
667
+ </div>
668
+ ))}
669
+ </div>
670
+ </div>
671
+ ) : (
672
+ <>
673
+ {activeFilters.map((f, idx) => {
674
+ const fieldDef = filterFields.find(fd => fd.key === f.fieldKey)
675
+ if (!fieldDef) return null
676
+ const leftId = idx > 0 ? activeFilters[idx - 1]!.id : null
677
+ const connector = leftId ? getFilterConnector(leftId) : "and"
678
+ return (
679
+ <React.Fragment key={f.id}>
680
+ {idx > 0 && leftId && (
681
+ <div className="flex items-center gap-2 py-1">
682
+ <div className="flex-1 h-px bg-border" aria-hidden="true" />
683
+ <Tip label="Click to switch: AND — every filter must match; OR — any matching filter is enough." side="top">
684
+ <button
685
+ type="button"
686
+ onClick={() => onToggleFilterConnector(leftId)}
687
+ className={cn(
688
+ "shrink-0 rounded-md border px-2.5 py-0.5 text-xs font-semibold uppercase tracking-wide transition-colors",
689
+ "border-border bg-muted/40 text-muted-foreground hover:bg-interactive-hover hover:text-interactive-hover-foreground",
690
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
691
+ )}
692
+ aria-label={
693
+ connector === "and"
694
+ ? "Filters are combined with AND. Click to use OR instead."
695
+ : "Filters are combined with OR. Click to use AND instead."
696
+ }
697
+ >
698
+ {connector}
699
+ </button>
700
+ </Tip>
701
+ <div className="flex-1 h-px bg-border" aria-hidden="true" />
702
+ </div>
703
+ )}
704
+ <DrawerFilterCard
705
+ filter={f}
706
+ fieldDef={fieldDef}
707
+ expanded={drawerExpandedFilters.has(f.id)}
708
+ onToggleExpand={() => onDrawerExpandedFiltersChange(prev => {
709
+ const next = new Set(prev)
710
+ if (next.has(f.id)) next.delete(f.id)
711
+ else next.add(f.id)
712
+ return next
713
+ })}
714
+ onUpdate={onUpdateFilter}
715
+ onRemove={id => {
716
+ onRemoveFilter(id)
717
+ onDrawerExpandedFiltersChange(prev => { const next = new Set(prev); next.delete(id); return next })
718
+ }}
719
+ renderOptionLabel={value => renderFilterOptionValue?.(f.fieldKey, value)}
720
+ />
721
+ </React.Fragment>
722
+ )
723
+ })}
724
+ </>
725
+ )}
726
+
727
+ {/* Add filter + Remove all */}
728
+ <div className="flex items-center gap-2 pt-2">
729
+ <DropdownMenu>
730
+ <DropdownMenuTrigger asChild>
731
+ <Button
732
+ type="button"
733
+ variant="outline"
734
+ className="flex-1 gap-1.5 h-8 border-dashed text-muted-foreground"
735
+ >
736
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
737
+ Add filter
738
+ </Button>
739
+ </DropdownMenuTrigger>
740
+ <DropdownMenuContent align="start" className="w-48">
741
+ <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
742
+ <DropdownMenuSeparator />
743
+ {filterFields.map(f => (
744
+ <DropdownMenuItem key={f.key} onClick={() => onAddFilter(f.key)}>
745
+ <i className={`fa-light ${f.icon}`} aria-hidden="true" />
746
+ {f.label}
747
+ </DropdownMenuItem>
748
+ ))}
749
+ </DropdownMenuContent>
750
+ </DropdownMenu>
751
+ {activeFilters.length > 0 && (
752
+ <Button
753
+ type="button"
754
+ variant="ghost"
755
+ size="sm"
756
+ className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
757
+ onClick={() => { activeFilters.forEach(f => onRemoveFilter(f.id)); onDrawerExpandedFiltersChange(new Set()) }}
758
+ >
759
+ Remove all
760
+ </Button>
761
+ )}
762
+ </div>
763
+
764
+ {/* Enable filter bar toggle */}
765
+ <div className="flex items-start justify-between gap-3 pt-3 mt-1 border-t border-border">
766
+ <div>
767
+ <label htmlFor="toggle-filter-bar" className="text-sm font-medium text-foreground cursor-pointer">Enable filter bar</label>
768
+ <p className="text-xs text-muted-foreground mt-0.5">Show filters above the table.</p>
769
+ </div>
770
+ <ToggleSwitch id="toggle-filter-bar" checked={filterBarVisible} onChange={onFilterBarVisibleChange} />
771
+ </div>
772
+ </div>
773
+ )}
774
+
775
+ {/* ── Sort ── */}
776
+ {sheetPanel === "sort" && (
777
+ <div className="px-4 py-4 space-y-2">
778
+ {sortRules.length === 0 ? (
779
+ /* Empty state */
780
+ <div className="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-6 text-center space-y-2">
781
+ <div className="inline-flex items-center justify-center size-9 rounded-lg bg-muted mb-1">
782
+ <i className="fa-light fa-arrow-up-arrow-down text-muted-foreground text-[16px]" aria-hidden="true" />
783
+ </div>
784
+ <p className="text-sm font-medium text-foreground">No sorts applied</p>
785
+ <p className="text-xs text-muted-foreground leading-relaxed">
786
+ Add a sort rule to order rows by any field. Multiple rules are applied in priority order.
787
+ </p>
788
+ <div className="space-y-1.5 text-left pt-1">
789
+ {[
790
+ { icon: "fa-circle-1", text: "Click \"Add sort\" below" },
791
+ { icon: "fa-circle-2", text: "Choose a field to sort by" },
792
+ { icon: "fa-circle-3", text: "Toggle ascending or descending" },
793
+ ].map(step => (
794
+ <div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
795
+ <i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
796
+ {step.text}
797
+ </div>
798
+ ))}
799
+ </div>
800
+ </div>
801
+ ) : (
802
+ sortRules.map((rule, idx) => {
803
+ const dragProps = sortDrag.getItemProps(rule.id)
804
+ return (
805
+ <React.Fragment key={rule.id}>
806
+ {idx > 0 && (
807
+ <div className="flex items-center gap-2 py-0.5">
808
+ <div className="flex-1 h-px bg-border" />
809
+ <span className="text-xs font-medium text-muted-foreground px-1">then by</span>
810
+ <div className="flex-1 h-px bg-border" />
811
+ </div>
812
+ )}
813
+ <div
814
+ {...dragProps}
815
+ className={cn(
816
+ "transition-all",
817
+ dragProps["data-dragging"] && "opacity-40",
818
+ dragProps["data-over"] && "ring-2 ring-ring bg-accent/30 rounded-lg",
819
+ )}
820
+ >
821
+ <DrawerSortCard
822
+ rule={rule}
823
+ fieldLabel={resolveColumnLabel(rule.fieldKey)}
824
+ isPrimary={idx === 0}
825
+ onRemove={() => onRemoveSortRule(rule.id)}
826
+ onToggleDir={() => onToggleSortDir(rule.id)}
827
+ />
828
+ </div>
829
+ </React.Fragment>
830
+ )
831
+ })
832
+ )}
833
+
834
+ {/* Add sort + Remove all */}
835
+ <div className="flex items-center gap-2 pt-2">
836
+ <DropdownMenu>
837
+ <DropdownMenuTrigger asChild>
838
+ <Button
839
+ type="button"
840
+ variant="outline"
841
+ className="flex-1 gap-1.5 h-8 border-dashed text-muted-foreground"
842
+ >
843
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
844
+ Add sort
845
+ </Button>
846
+ </DropdownMenuTrigger>
847
+ <DropdownMenuContent align="start" className="w-48">
848
+ <DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
849
+ <DropdownMenuSeparator />
850
+ {sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
851
+ <DropdownMenuItem key={col.key} onClick={() => onAddSortRule(col.key)}>
852
+ <i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
853
+ {col.label}
854
+ </DropdownMenuItem>
855
+ ))}
856
+ {sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).length === 0 && (
857
+ <p className="px-2 py-1.5 text-xs text-muted-foreground">All fields added</p>
858
+ )}
859
+ </DropdownMenuContent>
860
+ </DropdownMenu>
861
+ {sortRules.length > 0 && (
862
+ <Button
863
+ type="button"
864
+ variant="ghost"
865
+ size="sm"
866
+ className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
867
+ onClick={() => onSortRulesChange([])}
868
+ >
869
+ Remove all
870
+ </Button>
871
+ )}
872
+ </div>
873
+ </div>
874
+ )}
875
+
876
+ {/* ── Group ── */}
877
+ {sheetPanel === "group" && (
878
+ <div className="p-4 space-y-2">
879
+ <p className="text-xs text-muted-foreground mb-3">
880
+ {groupBy ? `Grouped by ${resolveColumnLabel(groupBy)}.` : "No grouping applied."}
881
+ </p>
882
+ <Button
883
+ type="button"
884
+ variant="ghost"
885
+ onClick={() => onGroupByChange(null)}
886
+ className={cn("w-full justify-start gap-2 px-3 py-2 h-auto text-sm font-normal",
887
+ !groupBy ? "bg-accent text-accent-foreground font-medium" : "text-muted-foreground",
888
+ )}
889
+ >
890
+ <i className="fa-light fa-ban text-xs" aria-hidden="true" />
891
+ None
892
+ </Button>
893
+ {groupFieldList.map(col => (
894
+ <Button
895
+ key={col.key}
896
+ type="button"
897
+ variant="ghost"
898
+ onClick={() => onGroupByChange(groupBy === col.key ? null : col.key)}
899
+ className={cn("w-full justify-start gap-2 px-3 py-2 h-auto text-sm font-normal",
900
+ groupBy === col.key ? "bg-accent text-accent-foreground font-medium" : "",
901
+ )}
902
+ >
903
+ <i className="fa-light fa-layer-group text-xs text-muted-foreground" aria-hidden="true" />
904
+ {col.label}
905
+ {groupBy === col.key && <i className="fa-solid fa-check text-accent-foreground text-xs ml-auto" aria-hidden="true" />}
906
+ </Button>
907
+ ))}
908
+ </div>
909
+ )}
910
+
911
+ {/* ── Columns ── */}
912
+ {sheetPanel === "columns" && (
913
+ <div className="px-4 py-4">
914
+ {isBoardView ? (
915
+ <p className="text-xs text-muted-foreground mb-3">
916
+ Column visibility and order apply when you use Table view. They are saved with this tab.
917
+ </p>
918
+ ) : null}
919
+ <p className="text-xs text-muted-foreground mb-3">
920
+ {hiddenCols.size === 0
921
+ ? "All columns visible. Drag to reorder."
922
+ : `${hiddenCols.size} column${hiddenCols.size !== 1 ? "s" : ""} hidden. Drag handle to reorder.`}
923
+ </p>
924
+ <div className="space-y-0.5" role="list" aria-label="Column order and visibility">
925
+ {orderable.map((key, idx, arr) => {
926
+ const dragProps = colDrag.getItemProps(key)
927
+ return (
928
+ <ColumnRow
929
+ key={key}
930
+ label={resolveColumnLabel(key)}
931
+ isFirst={idx === 0}
932
+ isLast={idx === arr.length - 1}
933
+ visible={!hiddenCols.has(key)}
934
+ onToggleVisible={() => onToggleColVisibility(key)}
935
+ onMoveUp={() => onMoveCol(key, "up")}
936
+ onMoveDown={() => onMoveCol(key, "down")}
937
+ draggable={dragProps.draggable}
938
+ onDragStart={dragProps.onDragStart}
939
+ onDragOver={dragProps.onDragOver}
940
+ onDrop={dragProps.onDrop}
941
+ onDragEnd={dragProps.onDragEnd}
942
+ isDragging={dragProps["data-dragging"]}
943
+ isOver={dragProps["data-over"]}
944
+ />
945
+ )
946
+ })}
947
+ </div>
948
+ </div>
949
+ )}
950
+
951
+ {/* ── Conditional rules ── */}
952
+ {sheetPanel === "conditional-rules" && (
953
+ <ConditionalRulesPanel
954
+ filterFields={filterFields}
955
+ rules={conditionalRules}
956
+ onAdd={onAddConditionalRule}
957
+ onRemove={onRemoveConditionalRule}
958
+ onUpdate={onUpdateConditionalRule}
959
+ renderFilterOptionValue={renderFilterOptionValue}
960
+ />
961
+ )}
962
+
963
+ </div>
964
+ </>
965
+ )}
966
+
967
+ </SheetContent>
968
+ </Sheet>
969
+ )
970
+ }
971
+
972
+ // ─────────────────────────────────────────────────────────────────────────────
973
+ // ConditionalRulesPanel — same DrawerFilterCard as filters (incl. operator cycle);
974
+ // highlight color lives inside the card. Adding a rule expands only that card (like
975
+ // add filter from drawer). No And/Or connectors.
976
+ // ─────────────────────────────────────────────────────────────────────────────
977
+
978
+ function ConditionalRulesPanel({
979
+ filterFields,
980
+ rules,
981
+ onAdd,
982
+ onRemove,
983
+ onUpdate,
984
+ renderFilterOptionValue,
985
+ }: {
986
+ filterFields: FilterFieldDef[]
987
+ rules: ConditionalRule[]
988
+ onAdd: (rule: Omit<ConditionalRule, "id">) => void
989
+ onRemove: (id: string) => void
990
+ onUpdate: (id: string, patch: Partial<ConditionalRule>) => void
991
+ renderFilterOptionValue?: (fieldKey: string, value: string) => React.ReactNode
992
+ }) {
993
+ const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => new Set())
994
+
995
+ const prevLenRef = React.useRef(rules.length)
996
+ React.useEffect(() => {
997
+ if (rules.length > prevLenRef.current && rules.length > 0) {
998
+ const last = rules[rules.length - 1]
999
+ setExpandedIds(new Set([last.id]))
1000
+ }
1001
+ prevLenRef.current = rules.length
1002
+ }, [rules])
1003
+
1004
+ function toggleExpanded(id: string) {
1005
+ setExpandedIds(prev => {
1006
+ const next = new Set(prev)
1007
+ if (next.has(id)) next.delete(id)
1008
+ else next.add(id)
1009
+ return next
1010
+ })
1011
+ }
1012
+
1013
+ return (
1014
+ <div className="px-4 py-4 space-y-2">
1015
+ {rules.length === 0 ? (
1016
+ <div className="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-6 text-center space-y-2">
1017
+ <div className="inline-flex items-center justify-center size-9 rounded-lg bg-muted mb-1">
1018
+ <i className="fa-light fa-palette text-muted-foreground text-[16px]" aria-hidden="true" />
1019
+ </div>
1020
+ <p className="text-sm font-medium text-foreground">No rules yet</p>
1021
+ <p className="text-xs text-muted-foreground leading-relaxed">
1022
+ Highlight cells with a background color based on their value.
1023
+ </p>
1024
+ </div>
1025
+ ) : (
1026
+ <div className="space-y-2">
1027
+ {rules.map(rule => {
1028
+ const fd = filterFields.find(f => f.key === rule.fieldKey)
1029
+ if (!fd) return null
1030
+ return (
1031
+ <DrawerFilterCard
1032
+ key={rule.id}
1033
+ variant="conditional"
1034
+ filter={rule}
1035
+ fieldDef={fd}
1036
+ expanded={expandedIds.has(rule.id)}
1037
+ onToggleExpand={() => toggleExpanded(rule.id)}
1038
+ onUpdate={onUpdate}
1039
+ onRemove={id => {
1040
+ onRemove(id)
1041
+ setExpandedIds(prev => {
1042
+ const next = new Set(prev)
1043
+ next.delete(id)
1044
+ return next
1045
+ })
1046
+ }}
1047
+ renderOptionLabel={value => renderFilterOptionValue?.(rule.fieldKey, value)}
1048
+ />
1049
+ )
1050
+ })}
1051
+ </div>
1052
+ )}
1053
+
1054
+ <div className="flex items-center gap-2 pt-2">
1055
+ <DropdownMenu>
1056
+ <DropdownMenuTrigger asChild>
1057
+ <Button
1058
+ type="button"
1059
+ variant="outline"
1060
+ className="flex-1 gap-1.5 h-8 border-dashed text-muted-foreground"
1061
+ >
1062
+ <i className="fa-light fa-plus text-xs" aria-hidden="true" />
1063
+ Add rule
1064
+ </Button>
1065
+ </DropdownMenuTrigger>
1066
+ <DropdownMenuContent align="start" className="w-48">
1067
+ <DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
1068
+ <DropdownMenuSeparator />
1069
+ {filterFields.map(f => (
1070
+ <DropdownMenuItem
1071
+ key={f.key}
1072
+ onClick={() => onAdd({
1073
+ fieldKey: f.key,
1074
+ operator: f.operators[0],
1075
+ values: [],
1076
+ bgColor: RULE_COLORS[0].bg,
1077
+ })}
1078
+ >
1079
+ <i className={`fa-light ${f.icon}`} aria-hidden="true" />
1080
+ {f.label}
1081
+ </DropdownMenuItem>
1082
+ ))}
1083
+ </DropdownMenuContent>
1084
+ </DropdownMenu>
1085
+ {rules.length > 0 && (
1086
+ <Button
1087
+ type="button"
1088
+ variant="ghost"
1089
+ size="sm"
1090
+ className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
1091
+ onClick={() => {
1092
+ rules.forEach(r => onRemove(r.id))
1093
+ setExpandedIds(new Set())
1094
+ }}
1095
+ >
1096
+ Remove all
1097
+ </Button>
1098
+ )}
1099
+ </div>
1100
+ </div>
1101
+ )
1102
+ }