@exxatdesignux/ui 0.2.15 → 0.2.17

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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -0,0 +1,131 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import Link from "next/link"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import {
8
+ Breadcrumb,
9
+ BreadcrumbItem,
10
+ BreadcrumbLink,
11
+ BreadcrumbList,
12
+ BreadcrumbPage,
13
+ BreadcrumbSeparator,
14
+ } from "@/components/ui/breadcrumb"
15
+
16
+ export interface PageBreadcrumbTrailItem {
17
+ label: string
18
+ href?: string
19
+ }
20
+
21
+ export interface PageBreadcrumbBackProps {
22
+ /** Destination label (e.g. "Question hub") — shown after the back icon. */
23
+ label: string
24
+ href: string
25
+ className?: string
26
+ }
27
+
28
+ export interface PageBreadcrumbTrailProps {
29
+ /** Linkable ancestors (e.g. Question hub). */
30
+ items?: PageBreadcrumbTrailItem[]
31
+ /**
32
+ * Final segment in the trail. Omit when the current page is the `PageHeader`
33
+ * `<h1>` — use ancestors-only above the title (no duplicate label).
34
+ */
35
+ currentPage?: string
36
+ /**
37
+ * `header` — SiteHeader: ancestors + `currentPage` on one line.
38
+ * `content` — ancestors only, above `PageHeader` title.
39
+ */
40
+ variant?: "header" | "content"
41
+ className?: string
42
+ }
43
+
44
+ /**
45
+ * Single-step back nav — back icon + parent destination (no chevron trail).
46
+ * Use in `SiteHeader` for focused child routes (composer, wizard) where the
47
+ * page `<h1>` is the current title.
48
+ */
49
+ export function PageBreadcrumbBack({ label, href, className }: PageBreadcrumbBackProps) {
50
+ return (
51
+ <Breadcrumb className={cn("min-w-0", className)}>
52
+ <BreadcrumbList className="gap-1.5 font-sans tracking-normal">
53
+ <BreadcrumbItem className="min-w-0">
54
+ <BreadcrumbLink asChild>
55
+ <Link
56
+ href={href}
57
+ className="group inline-flex min-w-0 max-w-full items-center gap-1.5 font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground"
58
+ aria-label={`Back to ${label}`}
59
+ >
60
+ <i
61
+ className="fa-light fa-arrow-left shrink-0 text-xs transition-transform group-hover:-translate-x-0.5"
62
+ aria-hidden="true"
63
+ />
64
+ <span className="truncate">{label}</span>
65
+ </Link>
66
+ </BreadcrumbLink>
67
+ </BreadcrumbItem>
68
+ </BreadcrumbList>
69
+ </Breadcrumb>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Product breadcrumb trail — one component for SiteHeader and in-page shells.
75
+ * Uses shadcn `Breadcrumb` primitives with Exxat site-header typography.
76
+ *
77
+ * For back-icon + parent label only, use {@link PageBreadcrumbBack}.
78
+ */
79
+ export function PageBreadcrumbTrail({
80
+ items = [],
81
+ currentPage,
82
+ variant = "content",
83
+ className,
84
+ }: PageBreadcrumbTrailProps) {
85
+ const isHeader = variant === "header"
86
+
87
+ return (
88
+ <Breadcrumb
89
+ className={cn("min-w-0", className)}
90
+ aria-label={isHeader ? undefined : "Breadcrumb"}
91
+ >
92
+ <BreadcrumbList
93
+ className={cn(
94
+ "gap-1.5 font-sans tracking-normal",
95
+ isHeader && "flex-nowrap overflow-hidden",
96
+ )}
97
+ >
98
+ {items.map((crumb, i) => (
99
+ <React.Fragment key={`${crumb.label}-${i}`}>
100
+ <BreadcrumbItem className="shrink-0">
101
+ {crumb.href ? (
102
+ <BreadcrumbLink asChild>
103
+ <Link
104
+ href={crumb.href}
105
+ className="font-sans text-sm text-muted-foreground"
106
+ >
107
+ {crumb.label}
108
+ </Link>
109
+ </BreadcrumbLink>
110
+ ) : (
111
+ <span className="font-sans text-sm text-muted-foreground">
112
+ {crumb.label}
113
+ </span>
114
+ )}
115
+ </BreadcrumbItem>
116
+ {(currentPage != null || i < items.length - 1) && (
117
+ <BreadcrumbSeparator className="text-muted-foreground/50 [&>i]:text-xs" />
118
+ )}
119
+ </React.Fragment>
120
+ ))}
121
+ {currentPage != null ? (
122
+ <BreadcrumbItem className="min-w-0">
123
+ <BreadcrumbPage className="truncate font-sans text-sm font-medium">
124
+ {currentPage}
125
+ </BreadcrumbPage>
126
+ </BreadcrumbItem>
127
+ ) : null}
128
+ </BreadcrumbList>
129
+ </Breadcrumb>
130
+ )
131
+ }
@@ -12,7 +12,7 @@
12
12
  * WCAG 2.1 AA:
13
13
  * ✓ <h1> landmark — one per page (WCAG 1.3.1)
14
14
  * ✓ Sufficient colour contrast ≥ 4.5:1 on title + subtitle (SC 1.4.3)
15
- * ✓ Face stack: `role="group"` + aggregate `aria-label`; each face has a `Tooltip` name
15
+ * ✓ Face row: `role="group"` + aggregate `aria-label`; each face has a `Tooltip` name
16
16
  */
17
17
 
18
18
  import * as React from "react"
@@ -23,6 +23,7 @@ import {
23
23
  } from "@/lib/collaborator-access"
24
24
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
25
25
  import { Button } from "@/components/ui/button"
26
+ import { Separator } from "@/components/ui/separator"
26
27
  import {
27
28
  Tooltip,
28
29
  TooltipContent,
@@ -46,17 +47,17 @@ export interface PageHeaderProps {
46
47
  /** Primary page title — rendered as <h1> in Ivy Presto serif */
47
48
  title: string
48
49
  /** Short descriptor or date shown below the title (and below `accessInfo` when set) */
49
- subtitle?: string
50
- /** Layout preset — `collaboration` enables access line + face stack ahead of `actions`. */
50
+ subtitle?: React.ReactNode
51
+ /** Layout preset — `collaboration` enables access line + face row ahead of `actions`. */
51
52
  variant?: PageHeaderVariant
52
53
  /**
53
54
  * Role / access copy or badges — rendered between the title and subtitle when
54
55
  * `variant="collaboration"` (e.g. lock icon + “Editors can modify”).
55
56
  */
56
57
  accessInfo?: React.ReactNode
57
- /** People with access — shown as an overlapping face stack when `variant="collaboration"`. */
58
+ /** People with access — shown as a horizontal row of faces when `variant="collaboration"`. */
58
59
  collaborators?: PageHeaderCollaborator[]
59
- /** Max faces before a `+N` chip — default 4 */
60
+ /** Max faces before a `+N` chip — default 3 */
60
61
  collaboratorDisplayLimit?: number
61
62
  /** Opens the invite collaborators sheet when a face, overflow chip, or empty-state CTA is activated. */
62
63
  onCollaboratorsOpen?: () => void
@@ -112,14 +113,13 @@ function PageHeaderCollaborationAccess({
112
113
  aria-label={names ? `People with access: ${names}` : "People with access"}
113
114
  className="flex shrink-0 items-center gap-2 sm:gap-2.5"
114
115
  >
115
- <div className="flex -space-x-2 ps-0.5">
116
- {visible.map((c, index) => (
116
+ <div className="flex shrink-0 items-center gap-1.5">
117
+ {visible.map(c => (
117
118
  <Tooltip key={c.id}>
118
119
  <TooltipTrigger asChild>
119
120
  <button
120
121
  type="button"
121
- className="relative rounded-full ring-2 ring-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
122
- style={{ zIndex: 10 + index }}
122
+ className="relative shrink-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
123
123
  aria-label={`Open collaborators — ${c.name}`}
124
124
  onClick={onOpenCollaborators}
125
125
  disabled={!onOpenCollaborators}
@@ -140,7 +140,7 @@ function PageHeaderCollaborationAccess({
140
140
  {overflow > 0 && (
141
141
  <button
142
142
  type="button"
143
- className="relative z-30 flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium tabular-nums text-muted-foreground ring-2 ring-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:size-7"
143
+ className="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold tabular-nums text-muted-foreground ring-1 ring-border/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
144
144
  aria-label={`Open collaborators — ${overflow} more people with access`}
145
145
  onClick={onOpenCollaborators}
146
146
  disabled={!onOpenCollaborators}
@@ -159,7 +159,7 @@ export function PageHeader({
159
159
  variant = "default",
160
160
  accessInfo,
161
161
  collaborators,
162
- collaboratorDisplayLimit = 4,
162
+ collaboratorDisplayLimit = 3,
163
163
  onCollaboratorsOpen,
164
164
  addCollaboratorLabel = COLLABORATION_HEADER_ADD_LABEL,
165
165
  actions,
@@ -170,6 +170,8 @@ export function PageHeader({
170
170
  const showAccess = Boolean(isCollaboration && accessInfo)
171
171
  const showCollaborationAccess = isCollaboration
172
172
  const showActionsColumn = Boolean(actions) || showCollaborationAccess
173
+ const showCollaboratorActionsSeparator =
174
+ showCollaborationAccess && Boolean(actions)
173
175
 
174
176
  return (
175
177
  <div
@@ -185,6 +187,7 @@ export function PageHeader({
185
187
  <h1
186
188
  className="text-2xl font-semibold tracking-tight leading-tight text-foreground"
187
189
  style={{ fontFamily: "var(--font-heading)" }}
190
+ suppressHydrationWarning
188
191
  >
189
192
  {title}
190
193
  </h1>
@@ -208,6 +211,13 @@ export function PageHeader({
208
211
  addCollaboratorLabel={addCollaboratorLabel}
209
212
  />
210
213
  ) : null}
214
+ {showCollaboratorActionsSeparator ? (
215
+ <Separator
216
+ orientation="vertical"
217
+ decorative
218
+ className="h-8 shrink-0"
219
+ />
220
+ ) : null}
211
221
  {actions}
212
222
  </div>
213
223
  )}
@@ -7,7 +7,7 @@
7
7
  import * as React from "react"
8
8
  import { cn } from "@/lib/utils"
9
9
  import type { Placement } from "@/lib/mock/placements"
10
- import { StatusBadge } from "@/components/data-list-table-cells"
10
+ import { StatusBadge } from "@/components/placements-table-cells"
11
11
  import { AvatarInitials } from "@/components/ui/avatar"
12
12
  import { Badge } from "@/components/ui/badge"
13
13
  import {
@@ -17,7 +17,7 @@ import {
17
17
  } from "@/components/ui/dropdown-menu"
18
18
  import { cn } from "@/lib/utils"
19
19
  import type { Placement } from "@/lib/mock/placements"
20
- import { StatusBadge as PlacementStatusBadge } from "@/components/data-list-table-cells"
20
+ import { StatusBadge as PlacementStatusBadge } from "@/components/placements-table-cells"
21
21
  import { placementReadinessBadgeClass } from "@/lib/list-status-badges"
22
22
 
23
23
  // ─────────────────────────────────────────────────────────────────────────────
@@ -27,7 +27,7 @@ import { type BoardCardLifecycleTabId } from "@/lib/placement-board-card-layout"
27
27
  import type { ConditionalRule } from "@/components/table-properties/types"
28
28
  import type { ColumnDef } from "@/components/data-table/types"
29
29
  import { Badge } from "@/components/ui/badge"
30
- import { BoardPlacementCard } from "@/components/data-views/placement-board-card"
30
+ import { BoardPlacementCard } from "@/components/placement-board-card"
31
31
  import { BoardNewCardPlaceholder } from "@/components/data-views/board-card-primitives"
32
32
 
33
33
  const PHASE_COLUMNS: { phase: PlacementPhase; label: string; description: string }[] = [
@@ -1,7 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * DataListClientdemo list hub on the reusable ListPageTemplate.
4
+ * PlacementsClientplacements hub composition on the reusable
5
+ * `ListPageTemplate`. Owns the per-page persisted layout (tabs, display
6
+ * options, show-metrics toggle) and mounts `PlacementsTable` per tab.
5
7
  *
6
8
  * Uses centralized exports from `@/components/data-views`.
7
9
  */
@@ -12,8 +14,8 @@ import { useSidebar } from "@/components/ui/sidebar"
12
14
  import {
13
15
  ListPageTemplate,
14
16
  type ViewTab,
15
- DataListTable,
16
- type DataListTableHandle,
17
+ PlacementsTable,
18
+ type PlacementsTableHandle,
17
19
  type PlacementLifecycleTabId,
18
20
  type DataListViewType,
19
21
  dataListViewIcon,
@@ -127,7 +129,7 @@ const LIFECYCLE_OPTIONS = [
127
129
  // Component
128
130
  // ─────────────────────────────────────────────────────────────────────────────
129
131
 
130
- export function DataListClient() {
132
+ export function PlacementsClient() {
131
133
  const router = useRouter()
132
134
  const { setOpen } = useSidebar()
133
135
  const [showMetrics, setShowMetrics] = React.useState(true)
@@ -135,7 +137,7 @@ export function DataListClient() {
135
137
  const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(DEFAULT_DATA_LIST_DISPLAY_OPTIONS)
136
138
  const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
137
139
  const [activeTabId, setActiveTabId] = React.useState<string>(DEFAULT_TABS[0]?.id ?? "")
138
- const tableRef = React.useRef<DataListTableHandle>(null)
140
+ const tableRef = React.useRef<PlacementsTableHandle>(null)
139
141
 
140
142
  const viewsTour = useCoachMark({
141
143
  flowId: "data-list-views-tour",
@@ -168,7 +170,7 @@ export function DataListClient() {
168
170
  React.useLayoutEffect(() => {
169
171
  const p = loadPageFromStorage()
170
172
  if (!p) return
171
- setDisplayOptions(prev => ({ ...DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ...p.displayOptions }))
173
+ setDisplayOptions({ ...DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ...p.displayOptions })
172
174
  setShowMetrics(p.showMetrics)
173
175
  setTabs(p.tabs)
174
176
  const nextActive = p.tabs.some(t => t.id === p.activeTabId) ? p.activeTabId : (p.tabs[0]?.id ?? "")
@@ -226,7 +228,7 @@ export function DataListClient() {
226
228
  renderContent={(tab, updateTab) => {
227
229
  const phase = segmentFilterToPhase(tab.filterId)
228
230
  return (
229
- <DataListTable
231
+ <PlacementsTable
230
232
  key={tab.id}
231
233
  ref={tableRef}
232
234
  view={tab.viewType}
@@ -3,17 +3,19 @@
3
3
  /**
4
4
  * PlacementsListView — full-width row layout for the data list (vs table grid / board columns).
5
5
  * Shares column visibility + lifecycle rules with Table Properties via the same board column model.
6
- * Long lists use window scroll virtualization (TanStack Virtual) to limit DOM size.
6
+ *
7
+ * Shell (empty state + virtualization above 80 rows) comes from the generic
8
+ * `DataRowList` primitive in `components/data-views/`. This file owns only
9
+ * the placement-specific row body (column-driven field visibility).
7
10
  */
8
11
 
9
12
  import * as React from "react"
10
13
  import { useRouter } from "next/navigation"
11
- import { useWindowVirtualizer } from "@tanstack/react-virtual"
12
- import { cn } from "@/lib/utils"
13
14
  import type { Placement } from "@/lib/mock/placements"
14
- import { StatusBadge } from "@/components/data-list-table-cells"
15
+ import { StatusBadge } from "@/components/placements-table-cells"
15
16
  import { Badge } from "@/components/ui/badge"
16
17
  import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
18
+ import { DataRowList } from "@/components/data-views/data-row-list"
17
19
  import {
18
20
  type BoardCardLifecycleTabId,
19
21
  isBoardFieldActive,
@@ -23,7 +25,7 @@ import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
23
25
  import type { ConditionalRule } from "@/components/table-properties/types"
24
26
  import type { ColumnDef } from "@/components/data-table/types"
25
27
 
26
- /** Above this count, the list is virtualized against the window scroll. */
28
+ /** Above this count, `DataRowList` virtualizes against the window scroll. */
27
29
  const VIRTUAL_ROWS_THRESHOLD = 80
28
30
  /** Initial row height guess (px); `measureElement` refines for variable content. */
29
31
  const ESTIMATE_ROW_PX = 100
@@ -126,106 +128,6 @@ function PlacementListRowContent({
126
128
  )
127
129
  }
128
130
 
129
- function PlacementListRow({
130
- row,
131
- tab,
132
- hiddenColKeys,
133
- boardColumns,
134
- conditionalRules,
135
- onOpen,
136
- }: {
137
- row: Placement
138
- tab: BoardCardLifecycleTabId
139
- hiddenColKeys: Set<string>
140
- boardColumns: ColumnDef<Placement>[]
141
- conditionalRules: ConditionalRule[] | undefined
142
- onOpen: (id: number) => void
143
- }) {
144
- return (
145
- <li>
146
- <PlacementListRowContent
147
- row={row}
148
- tab={tab}
149
- hiddenColKeys={hiddenColKeys}
150
- boardColumns={boardColumns}
151
- conditionalRules={conditionalRules}
152
- onOpen={onOpen}
153
- />
154
- </li>
155
- )
156
- }
157
-
158
- function PlacementsListViewVirtualized({
159
- rows,
160
- lifecycleTabId,
161
- hiddenColKeys,
162
- boardColumns,
163
- conditionalRules,
164
- onOpen,
165
- }: {
166
- rows: Placement[]
167
- lifecycleTabId: BoardCardLifecycleTabId
168
- hiddenColKeys: Set<string>
169
- boardColumns: ColumnDef<Placement>[]
170
- conditionalRules: ConditionalRule[] | undefined
171
- onOpen: (id: number) => void
172
- }) {
173
- const anchorRef = React.useRef<HTMLDivElement>(null)
174
- const [scrollMargin, setScrollMargin] = React.useState(0)
175
-
176
- const updateScrollMargin = React.useCallback(() => {
177
- const el = anchorRef.current
178
- if (!el) return
179
- setScrollMargin(el.getBoundingClientRect().top + window.scrollY)
180
- }, [])
181
-
182
- React.useLayoutEffect(() => {
183
- updateScrollMargin()
184
- window.addEventListener("resize", updateScrollMargin)
185
- return () => window.removeEventListener("resize", updateScrollMargin)
186
- }, [updateScrollMargin, rows.length, lifecycleTabId])
187
-
188
- const virtualizer = useWindowVirtualizer({
189
- count: rows.length,
190
- estimateSize: () => ESTIMATE_ROW_PX,
191
- overscan: 8,
192
- scrollMargin,
193
- })
194
-
195
- return (
196
- <div ref={anchorRef} className="px-4 pb-8 pt-2 lg:px-6">
197
- <ul
198
- role="list"
199
- className="relative m-0 w-full list-none p-0"
200
- style={{ height: virtualizer.getTotalSize() }}
201
- >
202
- {virtualizer.getVirtualItems().map(vr => {
203
- const row = rows[vr.index]
204
- if (!row) return null
205
- return (
206
- <li
207
- key={vr.key}
208
- data-index={vr.index}
209
- ref={virtualizer.measureElement}
210
- className="absolute left-0 top-0 w-full pb-2"
211
- style={{ transform: `translateY(${vr.start}px)` }}
212
- >
213
- <PlacementListRowContent
214
- row={row}
215
- tab={lifecycleTabId}
216
- hiddenColKeys={hiddenColKeys}
217
- boardColumns={boardColumns}
218
- conditionalRules={conditionalRules}
219
- onOpen={onOpen}
220
- />
221
- </li>
222
- )
223
- })}
224
- </ul>
225
- </div>
226
- )
227
- }
228
-
229
131
  export interface PlacementsListViewProps {
230
132
  rows: Placement[]
231
133
  lifecycleTabId: BoardCardLifecycleTabId
@@ -246,32 +148,16 @@ export function PlacementsListView({
246
148
  const router = useRouter()
247
149
  const onOpen = React.useCallback((id: number) => router.push(`/data-list/${id}`), [router])
248
150
 
249
- if (rows.length === 0) {
250
- return (
251
- <div className="px-4 py-16 text-center lg:px-6">
252
- <p className="text-sm text-muted-foreground">{emptyCopy}</p>
253
- </div>
254
- )
255
- }
256
-
257
- if (rows.length >= VIRTUAL_ROWS_THRESHOLD) {
258
- return (
259
- <PlacementsListViewVirtualized
260
- rows={rows}
261
- lifecycleTabId={lifecycleTabId}
262
- hiddenColKeys={hiddenColKeys}
263
- boardColumns={boardColumns}
264
- conditionalRules={conditionalRules}
265
- onOpen={onOpen}
266
- />
267
- )
268
- }
269
-
270
151
  return (
271
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
272
- {rows.map(row => (
273
- <PlacementListRow
274
- key={row.id}
152
+ <DataRowList<Placement>
153
+ rows={rows}
154
+ getRowId={row => row.id}
155
+ emptyState={emptyCopy}
156
+ ariaLabel="Placements"
157
+ virtualizeThreshold={VIRTUAL_ROWS_THRESHOLD}
158
+ estimatedRowHeight={ESTIMATE_ROW_PX}
159
+ renderRow={row => (
160
+ <PlacementListRowContent
275
161
  row={row}
276
162
  tab={lifecycleTabId}
277
163
  hiddenColKeys={hiddenColKeys}
@@ -279,7 +165,7 @@ export function PlacementsListView({
279
165
  conditionalRules={conditionalRules}
280
166
  onOpen={onOpen}
281
167
  />
282
- ))}
283
- </ul>
168
+ )}
169
+ />
284
170
  )
285
171
  }
@@ -1,9 +1,9 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { render, screen } from "@testing-library/react"
3
3
 
4
- import { HireBadge, ReadinessBadge, StatusBadge } from "./data-list-table-cells"
4
+ import { HireBadge, ReadinessBadge, StatusBadge } from "./placements-table-cells"
5
5
 
6
- describe("data-list-table-cells", () => {
6
+ describe("placements-table-cells", () => {
7
7
  it("renders StatusBadge label for confirmed", () => {
8
8
  render(<StatusBadge status="confirmed" />)
9
9
  expect(screen.getByText("Confirmed")).toBeInTheDocument()
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * Placement table cell primitives — extracted from data-list-table for reuse and easier testing.
4
+ * Placement table cell primitives — extracted from placements-table for reuse and easier testing.
5
5
  */
6
6
 
7
7
  import * as React from "react"
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Placements lifecycle columns, empty states, and Properties drawer labels.
5
- * Owned by the placements data-list feature (`DataListClient` / `/data-list`), not `DataListTable`.
5
+ * Owned by the placements feature (`PlacementsClient` / `/data-list`); consumed by `PlacementsTable`.
6
6
  */
7
7
 
8
8
  import { Badge } from "@/components/ui/badge"
@@ -16,7 +16,7 @@ import {
16
16
  RowActions,
17
17
  StatusBadge,
18
18
  WeeksProgressCell,
19
- } from "@/components/data-list-table-cells"
19
+ } from "@/components/placements-table-cells"
20
20
  import { uniquePlacementFieldOptions, type Placement } from "@/lib/mock/placements"
21
21
  import { formatDateUS } from "@/lib/date-filter"
22
22
  import type { PlacementLifecycleTabId } from "@/lib/placement-lifecycle"