@exxatdesignux/ui 0.2.17 → 0.2.19

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 (162) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  9. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
  10. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  11. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  12. package/consumer-extras/patterns/data-views-pattern.md +42 -3
  13. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  14. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  15. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
  16. package/package.json +2 -1
  17. package/src/components/ui/button-group.tsx +81 -0
  18. package/src/components/ui/button.tsx +4 -4
  19. package/src/components/ui/sidebar.tsx +2 -2
  20. package/src/globals.css +7 -1807
  21. package/src/theme.css +10 -1126
  22. package/src/tokens/README.md +15 -0
  23. package/src/tokens/base.css +337 -0
  24. package/src/tokens/high-contrast.css +1195 -0
  25. package/src/tokens/layers.css +224 -0
  26. package/src/tokens/tailwind-bridge.css +118 -0
  27. package/src/tokens/themes.css +201 -0
  28. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  29. package/template/AGENTS.md +66 -21
  30. package/template/app/(app)/dashboard/loading.tsx +3 -15
  31. package/template/app/(app)/dashboard/page.tsx +2 -14
  32. package/template/app/(app)/data-list/layout.tsx +43 -0
  33. package/template/app/(app)/data-list/page.tsx +2 -2
  34. package/template/app/(app)/error.tsx +22 -6
  35. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  36. package/template/app/(app)/examples/page.tsx +1 -0
  37. package/template/app/(app)/layout.tsx +13 -6
  38. package/template/app/(app)/loading.tsx +1 -18
  39. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  40. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  41. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  42. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  43. package/template/app/(app)/question-bank/page.tsx +2 -1
  44. package/template/app/(app)/settings/page.tsx +4 -5
  45. package/template/app/global-error.tsx +63 -0
  46. package/template/app/globals.css +7 -1934
  47. package/template/app/layout.tsx +2 -0
  48. package/template/components/app-route-loading.tsx +14 -0
  49. package/template/components/app-sidebar.tsx +71 -55
  50. package/template/components/data-table/index.tsx +31 -67
  51. package/template/components/data-table/use-table-state.ts +33 -6
  52. package/template/components/data-views/index.ts +37 -9
  53. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  54. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  55. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  57. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  58. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  59. package/template/components/exxat-product-logo.tsx +2 -6
  60. package/template/components/key-metrics.tsx +54 -22
  61. package/template/components/list-hub-board-view.tsx +68 -0
  62. package/template/components/list-hub-client.tsx +186 -0
  63. package/template/components/list-hub-list-view.tsx +36 -0
  64. package/template/components/list-hub-panel-activator.tsx +8 -0
  65. package/template/components/list-hub-secondary-nav.tsx +121 -0
  66. package/template/components/list-hub-table.tsx +336 -0
  67. package/template/components/new-question-composer.tsx +6 -24
  68. package/template/components/product-switcher.tsx +5 -5
  69. package/template/components/product-wordmark.tsx +4 -7
  70. package/template/components/question-bank-client.tsx +4 -1
  71. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  72. package/template/components/question-bank-hub-client.tsx +2 -5
  73. package/template/components/question-bank-table.tsx +155 -509
  74. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  75. package/template/components/secondary-panel.tsx +4 -44
  76. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  77. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  78. package/template/components/secondary-panels/registry.tsx +15 -0
  79. package/template/components/settings-appearance-card.tsx +3 -2
  80. package/template/components/settings-client.tsx +59 -15
  81. package/template/components/settings-form-row.tsx +9 -4
  82. package/template/components/sidebar-shell.tsx +2 -1
  83. package/template/components/table-properties/drawer-button.tsx +51 -20
  84. package/template/components/table-properties/drawer.tsx +81 -17
  85. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  86. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  87. package/template/components/templates/list-page.tsx +40 -13
  88. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
  89. package/template/components/templates/page-loading-shell.tsx +262 -0
  90. package/template/components/ui/button-group.tsx +1 -0
  91. package/template/contexts/product-context.tsx +21 -2
  92. package/template/docs/consumer-app-pattern.md +39 -0
  93. package/template/docs/data-views-pattern.md +42 -3
  94. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  95. package/template/docs/focused-workflow-page-pattern.md +84 -0
  96. package/template/docs/kpi-flat-band-pattern.md +57 -0
  97. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  98. package/template/docs/shell-surface-elevation-pattern.md +54 -0
  99. package/template/lib/chunk-load-error.ts +13 -0
  100. package/template/lib/command-menu-search-data.ts +11 -27
  101. package/template/lib/conditional-rule-match.ts +87 -22
  102. package/template/lib/data-list-display-options.ts +16 -2
  103. package/template/lib/data-list-view-registry.ts +104 -0
  104. package/template/lib/data-list-view-surface.ts +15 -1
  105. package/template/lib/data-list-view.ts +16 -1
  106. package/template/lib/data-view-dashboard-storage.ts +38 -35
  107. package/template/lib/hub-connected-view-renderers.ts +58 -0
  108. package/template/lib/list-hub-nav.ts +121 -0
  109. package/template/lib/list-hub-supported-views.ts +10 -0
  110. package/template/lib/list-page-table-properties.ts +3 -7
  111. package/template/lib/list-status-badges.ts +4 -97
  112. package/template/lib/mock/list-hub-directory.ts +27 -0
  113. package/template/lib/mock/list-hub-kpi.ts +27 -0
  114. package/template/lib/mock/navigation.tsx +1 -0
  115. package/template/lib/page-loading-variant.ts +40 -0
  116. package/template/lib/question-bank-supported-views.ts +13 -0
  117. package/template/lib/sidebar-state-cookie.ts +9 -0
  118. package/template/lib/table-state-lifecycle.ts +60 -13
  119. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  120. package/template/app/(app)/data-list/new/page.tsx +0 -34
  121. package/template/components/compliance-board-view.tsx +0 -142
  122. package/template/components/compliance-client.tsx +0 -92
  123. package/template/components/compliance-list-view.tsx +0 -54
  124. package/template/components/compliance-page-header.tsx +0 -89
  125. package/template/components/compliance-table.tsx +0 -632
  126. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  127. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  128. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  129. package/template/components/new-placement-back-btn.tsx +0 -28
  130. package/template/components/new-placement-form.tsx +0 -1068
  131. package/template/components/placement-board-card.tsx +0 -262
  132. package/template/components/placement-detail.tsx +0 -438
  133. package/template/components/placements-board-view.tsx +0 -404
  134. package/template/components/placements-client.tsx +0 -252
  135. package/template/components/placements-list-view.tsx +0 -171
  136. package/template/components/placements-page-header.tsx +0 -166
  137. package/template/components/placements-table-cells.test.tsx +0 -22
  138. package/template/components/placements-table-cells.tsx +0 -173
  139. package/template/components/placements-table-columns.tsx +0 -640
  140. package/template/components/placements-table.tsx +0 -1675
  141. package/template/components/rotations-empty-state.tsx +0 -50
  142. package/template/components/rotations-panel-activator.tsx +0 -8
  143. package/template/components/sites-all-client.tsx +0 -154
  144. package/template/components/sites-board-view.tsx +0 -67
  145. package/template/components/sites-list-view.tsx +0 -42
  146. package/template/components/sites-table.tsx +0 -402
  147. package/template/components/team-board-view.tsx +0 -122
  148. package/template/components/team-client.tsx +0 -100
  149. package/template/components/team-list-view.tsx +0 -59
  150. package/template/components/team-page-header.tsx +0 -92
  151. package/template/components/team-table.tsx +0 -714
  152. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  153. package/template/lib/mock/compliance-kpi.ts +0 -61
  154. package/template/lib/mock/compliance.ts +0 -146
  155. package/template/lib/mock/placements-kpi.ts +0 -134
  156. package/template/lib/mock/placements.ts +0 -183
  157. package/template/lib/mock/sites-directory.ts +0 -16
  158. package/template/lib/mock/sites-kpi.ts +0 -25
  159. package/template/lib/mock/team-kpi.ts +0 -60
  160. package/template/lib/mock/team.ts +0 -118
  161. package/template/lib/placement-board-card-layout.ts +0 -79
  162. package/template/lib/placement-lifecycle.ts +0 -5
@@ -6,6 +6,7 @@ import "./globals.css"
6
6
  import { ThemeProvider } from "@/components/theme-provider"
7
7
  import { TooltipProvider } from "@/components/ui/tooltip"
8
8
  import { ProductProvider } from "@/contexts/product-context"
9
+ import { DevChunkLoadRecovery } from "@/components/dev-chunk-load-recovery"
9
10
  import { ThemeColorSync } from "@/components/theme-color-sync"
10
11
  import { cn } from "@/lib/utils"
11
12
 
@@ -90,6 +91,7 @@ export default function RootLayout({
90
91
  />
91
92
  </head>
92
93
  <body className="bg-sidebar text-foreground font-sans">
94
+ <DevChunkLoadRecovery />
93
95
  {/*
94
96
  * Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json +
95
97
  * fontawesome.com/kits (Icon Selection).
@@ -0,0 +1,14 @@
1
+ "use client"
2
+
3
+ import { usePathname } from "next/navigation"
4
+
5
+ import { PageLoadingByVariant } from "@/components/templates/page-loading-shell"
6
+ import { resolvePageLoadingVariant } from "@/lib/page-loading-variant"
7
+
8
+ /**
9
+ * Route-level loading UI — keeps `SidebarInset` + header chrome; skeleton matches destination template.
10
+ */
11
+ export function AppRouteLoading() {
12
+ const pathname = usePathname()
13
+ return <PageLoadingByVariant variant={resolvePageLoadingVariant(pathname)} />
14
+ }
@@ -105,11 +105,22 @@ function normalizedLocationHash(locationHash: string): string {
105
105
  return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
106
106
  }
107
107
 
108
+ /**
109
+ * Paths where a sibling sidebar row uses `#fragment` on the same pathname
110
+ * (e.g. Settings vs Tokens & themes). The plain-path row must not stay active
111
+ * when the hash belongs to that sibling.
112
+ */
113
+ const NAV_EXCLUSIVE_HASH_BY_PATH: Readonly<Record<string, readonly string[]>> = {
114
+ "/settings": ["appearance"],
115
+ "/help": ["more"],
116
+ }
117
+
108
118
  /**
109
119
  * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
110
120
  * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
111
121
  * in each `href` — those rows use the `frag !== null` branch below.
112
- * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
122
+ * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match,
123
+ * except when the hash is reserved for a hash-sibling row on that path.
113
124
  */
114
125
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
115
126
  const pathOnly = navUrlPath(url)
@@ -131,7 +142,13 @@ function isNavActive(pathname: string, url: string, locationHash = ""): boolean
131
142
 
132
143
  if (pathOnly === "/") return pathname === "/" && h === ""
133
144
  /** Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash). */
134
- if (pathname === pathOnly) return true
145
+ if (pathname === pathOnly) {
146
+ if (h !== "") {
147
+ const exclusive = NAV_EXCLUSIVE_HASH_BY_PATH[pathOnly]
148
+ if (exclusive?.includes(h)) return false
149
+ }
150
+ return true
151
+ }
135
152
  // Design system library — active on hub and detail routes.
136
153
  if (pathOnly === "/library") {
137
154
  return pathname.startsWith("/library/")
@@ -827,60 +844,58 @@ function ProductLogoButton() {
827
844
 
828
845
  return (
829
846
  <DropdownMenu>
830
- <Tooltip>
831
- <TooltipTrigger asChild>
832
- <DropdownMenuTrigger asChild>
833
- <SidebarMenuButton
834
- size="lg"
835
- className={cn(
836
- "py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
837
- expandedOrMobile &&
838
- "h-auto min-h-12 !overflow-visible items-center [&>span:last-child]:!overflow-visible [&>span:last-child]:!whitespace-normal [&>span:last-child]:text-clip",
839
- "group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center",
840
- iconRail &&
841
- "group-data-[collapsible=icon]:!size-9 group-data-[collapsible=icon]:!min-h-9 group-data-[collapsible=icon]:!max-h-9 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:overflow-visible",
842
- )}
843
- aria-label={`Current product: ${current.label}. Switch product`}
844
- suppressHydrationWarning
845
- >
846
- {iconRail ? (
847
- // Match the school selector footprint in the icon rail; the
848
- // inner mark cutout uses the rail surface instead of a white fill.
849
- <span className="flex size-8 shrink-0 items-center justify-center">
850
- <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
851
- </span>
852
- ) : (
853
- <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
854
- <span
855
- className="flex min-h-0 min-w-0 flex-1 items-center justify-start overflow-visible"
856
- aria-hidden="true"
857
- >
858
- <ExxatProductLogo
859
- product={current.id}
860
- variant="mutedSuffix"
861
- className="w-auto max-w-[min(100%,280px)] object-left object-contain"
862
- />
863
- </span>
864
- <span
865
- className="flex w-6 shrink-0 items-center justify-center self-stretch text-muted-foreground"
866
- aria-hidden="true"
867
- >
868
- <i
869
- className="fa-light fa-chevron-down block text-xs leading-none"
870
- aria-hidden="true"
871
- />
872
- </span>
873
- </span>
874
- )}
875
- </SidebarMenuButton>
876
- </DropdownMenuTrigger>
877
- </TooltipTrigger>
878
- <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile}>
879
- {current.label}
880
- </TooltipContent>
881
- </Tooltip>
847
+ <DropdownMenuTrigger asChild>
848
+ <SidebarMenuButton
849
+ size="lg"
850
+ tooltip={iconRail ? current.label : undefined}
851
+ className={cn(
852
+ "py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
853
+ expandedOrMobile &&
854
+ "h-auto min-h-12 !overflow-visible items-center [&>span:last-child]:!overflow-visible [&>span:last-child]:!whitespace-normal [&>span:last-child]:text-clip",
855
+ "group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center",
856
+ iconRail &&
857
+ "group-data-[collapsible=icon]:!size-9 group-data-[collapsible=icon]:!min-h-9 group-data-[collapsible=icon]:!max-h-9 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:overflow-visible",
858
+ )}
859
+ aria-label={`Current product: ${current.label}. Switch product`}
860
+ suppressHydrationWarning
861
+ >
862
+ {iconRail ? (
863
+ // Match the school selector footprint in the icon rail (32px frame,
864
+ // 28px mark same visual weight as the avatar with inset padding).
865
+ <span className="flex size-8 shrink-0 items-center justify-center">
866
+ <ExxatProductMark product={current.id} className="size-7" />
867
+ </span>
868
+ ) : (
869
+ <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
870
+ <span
871
+ className="flex min-h-0 min-w-0 flex-1 items-center justify-start overflow-visible"
872
+ aria-hidden="true"
873
+ >
874
+ <ExxatProductLogo
875
+ product={current.id}
876
+ variant="mutedSuffix"
877
+ className="w-auto max-w-[min(100%,280px)] object-left object-contain"
878
+ />
879
+ </span>
880
+ <span
881
+ className="flex w-6 shrink-0 items-center justify-center self-stretch text-muted-foreground"
882
+ aria-hidden="true"
883
+ >
884
+ <i
885
+ className="fa-light fa-chevron-down block text-xs leading-none"
886
+ aria-hidden="true"
887
+ />
888
+ </span>
889
+ </span>
890
+ )}
891
+ </SidebarMenuButton>
892
+ </DropdownMenuTrigger>
882
893
 
883
- <DropdownMenuContent align="start" side="right" sideOffset={8}>
894
+ <DropdownMenuContent
895
+ align="start"
896
+ side={iconRail ? "right" : "bottom"}
897
+ sideOffset={iconRail ? 8 : 4}
898
+ >
884
899
  <DropdownMenuLabel className="text-xs text-muted-foreground">
885
900
  Switch product
886
901
  </DropdownMenuLabel>
@@ -1004,6 +1019,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
1004
1019
  <SidebarGroupLabel
1005
1020
  id="sidebar-documents-heading"
1006
1021
  className="text-xs font-medium uppercase tracking-wide px-2 text-sidebar-section-label"
1022
+ suppressHydrationWarning
1007
1023
  >
1008
1024
  {NAV_DOCUMENTS_LABEL}
1009
1025
  </SidebarGroupLabel>
@@ -56,7 +56,8 @@ import {
56
56
  TooltipTrigger,
57
57
  } from "@/components/ui/tooltip"
58
58
  import { OPERATOR_LABELS } from "@/components/table-properties/types"
59
- import type { ActiveFilter, FilterTextMask } from "@/components/table-properties/types"
59
+ import type { ActiveFilter } from "@/components/table-properties/types"
60
+ import { getConditionalCellBackground } from "@/lib/conditional-rule-match"
60
61
  import { formatYmdForDisplay } from "@/lib/date-filter"
61
62
  import { FilterDateCalendar } from "@/components/data-table/filter-date-calendar"
62
63
  import { FilterTextValueInput } from "@/components/data-table/filter-text-value-input"
@@ -81,26 +82,6 @@ function resolvedColumnLabel<TData>(col: ColumnDef<TData>): string {
81
82
  return defaultColumnHeaderLabel(col.key) ?? col.key
82
83
  }
83
84
 
84
- function conditionalTextMatches(
85
- cellVal: string,
86
- needle: string,
87
- op: "contains" | "not_contains",
88
- textMask: FilterTextMask | undefined,
89
- ) {
90
- const v = cellVal.trim()
91
- const n = needle.trim()
92
- if (!n) return op === "not_contains"
93
- if (textMask === "phone" || textMask === "zip") {
94
- const nd = n.replace(/\D/g, "")
95
- const hay = v.replace(/\D/g, "")
96
- if (!nd) return op === "not_contains"
97
- const hit = hay.includes(nd)
98
- return op === "contains" ? hit : !hit
99
- }
100
- const hit = v.toLowerCase().includes(n.toLowerCase())
101
- return op === "contains" ? hit : !hit
102
- }
103
-
104
85
  // ─────────────────────────────────────────────────────────────────────────────
105
86
  // Internal sub-components
106
87
  // ─────────────────────────────────────────────────────────────────────────────
@@ -802,12 +783,19 @@ function DataTableInner<TData extends Record<string, unknown>>({
802
783
  setSheetOpen,
803
784
  } = state
804
785
 
805
- // Mount overflow check
786
+ // Mount overflow check + scrollport width for sticky group headers on horizontal scroll.
806
787
  React.useEffect(() => {
807
- checkOverflow()
788
+ const syncScrollport = () => {
789
+ const el = scrollRef.current
790
+ if (el) {
791
+ el.style.setProperty("--dt-scrollport-width", `${el.clientWidth}px`)
792
+ }
793
+ checkOverflow()
794
+ }
795
+ syncScrollport()
808
796
  const el = scrollRef.current
809
797
  if (!el) return
810
- const ro = new ResizeObserver(checkOverflow)
798
+ const ro = new ResizeObserver(syncScrollport)
811
799
  ro.observe(el)
812
800
  return () => ro.disconnect()
813
801
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1351,18 +1339,19 @@ function DataTableInner<TData extends Record<string, unknown>>({
1351
1339
  <React.Fragment key={groupKey ?? "__all__"}>
1352
1340
  {groupLabel && (
1353
1341
  <tr>
1354
- <td
1355
- colSpan={displayCols.length}
1356
- className={cn(
1357
- "px-4 py-1.5 text-xs font-semibold text-muted-foreground tracking-wide bg-dt-group-bg select-none",
1358
- !isReflowViewport && "sticky left-0",
1359
- "border-b border-border",
1360
- )}
1361
- >
1362
- {groupLabel}
1363
- <span className="ml-2 font-normal normal-case opacity-60 tracking-normal">
1364
- {groupRows.length} record{groupRows.length !== 1 ? "s" : ""}
1365
- </span>
1342
+ <td colSpan={displayCols.length} className="p-0 border-b border-border bg-dt-group-bg">
1343
+ <div
1344
+ className={cn(
1345
+ "sticky left-0 z-[25] px-4 py-1.5 text-xs font-semibold text-muted-foreground tracking-wide bg-dt-group-bg select-none",
1346
+ !isReflowViewport && "shadow-[4px_0_8px_-4px_var(--sticky-edge-fade)]",
1347
+ )}
1348
+ style={{ width: "var(--dt-scrollport-width, 100%)" }}
1349
+ >
1350
+ {groupLabel}
1351
+ <span className="ml-2 font-normal normal-case opacity-60 tracking-normal">
1352
+ {groupRows.length} record{groupRows.length !== 1 ? "s" : ""}
1353
+ </span>
1354
+ </div>
1366
1355
  </td>
1367
1356
  </tr>
1368
1357
  )}
@@ -1419,37 +1408,12 @@ function DataTableInner<TData extends Record<string, unknown>>({
1419
1408
  ]
1420
1409
  )
1421
1410
 
1422
- // Conditional rule background for this cell
1423
- const conditionalBg = conditionalRules?.find(rule => {
1424
- if (rule.fieldKey !== col.key) return false
1425
- const cellVal = String(row[rule.fieldKey as keyof TData] ?? "")
1426
- const v = cellVal.trim()
1427
- const ruleCol = columns.find(c => c.key === rule.fieldKey)
1428
- const textMask =
1429
- ruleCol?.filter?.type === "text" ? ruleCol.filter.textMask : undefined
1430
- switch (rule.operator) {
1431
- case "is":
1432
- return rule.values.length > 0 && rule.values.includes(v)
1433
- case "is_not":
1434
- return rule.values.length > 0 && !rule.values.includes(v)
1435
- case "contains":
1436
- return (
1437
- rule.values.length > 0 &&
1438
- rule.values.some(val =>
1439
- conditionalTextMatches(v, val, "contains", textMask),
1440
- )
1441
- )
1442
- case "not_contains":
1443
- return (
1444
- rule.values.length > 0 &&
1445
- !rule.values.some(val =>
1446
- conditionalTextMatches(v, val, "contains", textMask),
1447
- )
1448
- )
1449
- default:
1450
- return false
1451
- }
1452
- })?.bgColor
1411
+ const conditionalBg = getConditionalCellBackground(
1412
+ row,
1413
+ col.key,
1414
+ conditionalRules,
1415
+ columns,
1416
+ )
1453
1417
 
1454
1418
  const tdStyle = conditionalBg
1455
1419
  ? { ...cs, background: conditionalBg }
@@ -112,7 +112,8 @@ export function useTableState<TData extends Record<string, unknown>>(
112
112
  const addSortRule = React.useCallback((fieldKey: string) => {
113
113
  setSortRules(prev => {
114
114
  if (prev.some(r => r.fieldKey === fieldKey)) return prev
115
- return [...prev, { id: `sort-${Date.now()}`, fieldKey, direction: "asc" }]
115
+ // New drawer sorts are primary (same as column-header sort), not trailing.
116
+ return [{ id: `sort-${Date.now()}`, fieldKey, direction: "asc" }, ...prev]
116
117
  })
117
118
  }, [setSortRules])
118
119
 
@@ -178,9 +179,12 @@ export function useTableState<TData extends Record<string, unknown>>(
178
179
  }
179
180
  return f.operators?.[0] ?? "contains"
180
181
  })()
181
- setActiveFilters(prev => [...prev, { id, fieldKey, operator: firstOperator, values: [] }])
182
+ const newFilter: ActiveFilter = { id, fieldKey, operator: firstOperator, values: [] }
183
+ setActiveFilters(prev => [...prev, newFilter])
182
184
  if (fromDrawer) {
183
- setDrawerExpandedFilters(new Set([id]))
185
+ setDrawerExpandedFilters(() => new Set([id]))
186
+ // Keep toolbar pills hidden until a value is chosen — avoids mounting every
187
+ // FilterPill (heavy) on each drawer "Add filter" click.
184
188
  } else {
185
189
  setOpenFilterId(id)
186
190
  setFilterBarVisible(true)
@@ -188,8 +192,24 @@ export function useTableState<TData extends Record<string, unknown>>(
188
192
  }, [columns, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setFilterBarVisible])
189
193
 
190
194
  const updateFilter = React.useCallback((id: string, patch: Partial<ActiveFilter>) => {
191
- setActiveFilters(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f))
192
- }, [setActiveFilters])
195
+ let shouldShowFilterBar = false
196
+ setActiveFilters(prev => {
197
+ const next = prev.map(f => {
198
+ if (f.id !== id) return f
199
+ const merged = { ...f, ...patch }
200
+ const col = columns.find(c => c.key === merged.fieldKey)
201
+ if (merged.values.length > 0) {
202
+ shouldShowFilterBar =
203
+ col?.filter?.type === "text"
204
+ ? (merged.values[0] ?? "").trim().length > 0
205
+ : true
206
+ }
207
+ return merged
208
+ })
209
+ return next
210
+ })
211
+ if (shouldShowFilterBar) setFilterBarVisible(true)
212
+ }, [columns, setActiveFilters, setFilterBarVisible])
193
213
 
194
214
  const removeFilter = React.useCallback((id: string) => {
195
215
  // Use functional updates only — no stale-closure risk on activeFilters.
@@ -342,7 +362,14 @@ export function useTableState<TData extends Record<string, unknown>>(
342
362
  result = result.filter(r => getSearchableText(r).includes(q))
343
363
  }
344
364
 
345
- const activeWithValues = activeFilters.filter(f => f.values.length > 0)
365
+ const activeWithValues = activeFilters.filter(f => {
366
+ if (f.values.length === 0) return false
367
+ const col = columnsByKey.get(f.fieldKey)
368
+ if (col?.filter?.type === "text") {
369
+ return (f.values[0] ?? "").trim().length > 0
370
+ }
371
+ return true
372
+ })
346
373
  if (activeWithValues.length > 0) {
347
374
  // Pre-resolve column, operator, normalised needle, and select-value Set
348
375
  // for each active filter ONCE (instead of per row).
@@ -1,15 +1,12 @@
1
1
  /**
2
2
  * Central exports for list-page data surfaces and shared view chrome.
3
3
  *
4
- * **Pattern:** `ListPageTemplate` + `PlacementsTable` (or any hub-specific `*-table.tsx`) — one `useTableState`, one toolbar,
4
+ * **Pattern:** `ListPageTemplate` + hub `*-table.tsx` — one `useTableState`, one toolbar,
5
5
  * table | list | board | dashboard from the same component (`AGENTS.md` §4, `docs/data-views-pattern.md`).
6
6
  *
7
7
  * **View UI:** `ViewSegmentedControl` matches the template’s views toolbar (`bg-muted/60` pills).
8
8
  */
9
9
 
10
- export { PlacementsTable } from "@/components/placements-table"
11
- export type { PlacementsTableProps, PlacementsTableHandle } from "@/components/placements-table"
12
- export type { PlacementLifecycleTabId } from "@/lib/placement-lifecycle"
13
10
  export type { DataListViewType } from "@/lib/data-list-view"
14
11
  export { DATA_LIST_VIEW_TILES, dataListViewIcon, dataListViewLabel } from "@/lib/data-list-view"
15
12
 
@@ -39,6 +36,7 @@ export {
39
36
  export {
40
37
  ListPageSplitHubChrome,
41
38
  LIST_PAGE_SPLIT_HUB_HEIGHT_STYLE,
39
+ LIST_PAGE_CALENDAR_HEIGHT_STYLE,
42
40
  type ListPageSplitHubChromeProps,
43
41
  } from "@/components/data-views/list-page-split-hub-chrome"
44
42
 
@@ -63,8 +61,7 @@ export {
63
61
  type OutlineTreeSurface,
64
62
  } from "@/components/data-views/outline-tree-menu"
65
63
 
66
- export { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
67
- export type { QuestionBankFolderTreeBranchProps } from "@/components/data-views/question-bank-folder-tree-branch"
64
+ /** Question-bank nav only — import from `@/components/data-views/question-bank-folder-tree-branch`. */
68
65
 
69
66
  export {
70
67
  LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
@@ -83,10 +80,12 @@ export {
83
80
  type FolderDetailsShellProps,
84
81
  } from "@/components/folder-details-shell"
85
82
 
83
+ /** Hub-specific tree+inspector — import from `@/components/hub-tree-panel-view` (not generic DS). */
84
+
86
85
  export {
87
- HubTreePanelView,
88
- type HubTreePanelViewProps,
89
- } from "@/components/hub-tree-panel-view"
86
+ ListPageFolderColumnsPanel,
87
+ type ListPageFolderColumnsPanelProps,
88
+ } from "@/components/data-views/list-page-folder-columns-panel"
90
89
 
91
90
  export {
92
91
  ListPageTreePanelShell,
@@ -103,6 +102,35 @@ export {
103
102
  /** Generic folder icon-grid — reusable across all list hubs. */
104
103
  export { FolderGridView, type FolderGridViewProps } from "@/components/data-views/folder-grid-view"
105
104
 
105
+ /** Month calendar — same `tableState.rows` as table / list / board. */
106
+ export {
107
+ ListPageCalendarView,
108
+ type ListPageCalendarViewProps,
109
+ } from "@/components/data-views/list-page-calendar-view"
110
+
111
+ /** Hub view router — switch on `DataListViewRenderKind`; missing renderer = explicit empty state. */
112
+ export {
113
+ ListPageConnectedViewBody,
114
+ ListPageViewNotConfigured,
115
+ type ListPageConnectedViewBodyProps,
116
+ type ListPageConnectedViewRenderers,
117
+ } from "@/components/data-views/list-page-connected-view-body"
118
+
119
+ export {
120
+ DATA_LIST_VIEW_REGISTRY,
121
+ dataListViewDefinition,
122
+ dataListViewTilesForHub,
123
+ showsListPageHubMetricsStrip,
124
+ isDataListViewTypeSupported,
125
+ } from "@/lib/data-list-view-registry"
126
+
127
+ export {
128
+ defineHubViewRenderers,
129
+ hubRenderKindsForSupported,
130
+ type HubConnectedViewRenderers,
131
+ type HubRenderKindForViews,
132
+ } from "@/lib/hub-connected-view-renderers"
133
+
106
134
  /** Generic vertical row list — used by every hub's "list" tab. Composes
107
135
  * `ListPageBoardCard layout="row"` via a `renderRow` prop. */
108
136
  export { DataRowList, type DataRowListProps } from "@/components/data-views/data-row-list"