@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -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 +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  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 +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * SidebarShell — SidebarProvider with layout-aware widths.
5
5
  * Desktop expanded/collapsed is persisted in the `sidebar_state` cookie by `@exxatdesignux/ui`
6
- * `SidebarProvider` (read on mount + write on toggle).
6
+ * `SidebarProvider` (read on mount + write on toggle). `(app)/layout` passes
7
+ * `defaultOpen` from the same cookie on the server so SSR matches the first client paint.
7
8
  */
8
9
 
9
10
  import * as React from "react"
@@ -12,7 +12,12 @@
12
12
  */
13
13
 
14
14
  import * as React from "react"
15
- import Link from "next/link"
15
+ import {
16
+ PageBreadcrumbBack,
17
+ PageBreadcrumbTrail,
18
+ type PageBreadcrumbBackProps,
19
+ type PageBreadcrumbTrailItem,
20
+ } from "@/components/page-breadcrumb-trail"
16
21
  import { Separator } from "@/components/ui/separator"
17
22
  import { SidebarTrigger } from "@/components/ui/sidebar"
18
23
  import { Kbd, KbdGroup } from "@/components/ui/kbd"
@@ -25,19 +30,26 @@ import { AskLeoToggle } from "@/components/ask-leo-sidebar"
25
30
  import { useModKeyLabel } from "@/hooks/use-mod-key-label"
26
31
  import { cn } from "@/lib/utils"
27
32
 
28
- export interface BreadcrumbItem {
29
- label: string
30
- href?: string
31
- }
33
+ export type BreadcrumbItem = PageBreadcrumbTrailItem
34
+ export type SiteHeaderBackLink = Pick<PageBreadcrumbBackProps, "label" | "href">
32
35
 
33
36
  export interface SiteHeaderProps {
34
- /** Current page title (last breadcrumb segment) */
37
+ /** Current page title (last breadcrumb segment in trail mode). */
35
38
  title?: string
36
39
  /** Full breadcrumb trail — each item can be a link or plain text. Title is appended automatically as the last segment. */
37
40
  breadcrumbs?: BreadcrumbItem[]
41
+ /**
42
+ * Back-icon variant — parent link only (no `title` segment in the header).
43
+ * Prefer when the page `<h1>` carries the current title (e.g. New question composer).
44
+ */
45
+ back?: SiteHeaderBackLink
38
46
  }
39
47
 
40
- export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps) {
48
+ export function SiteHeader({
49
+ title = "Dashboard",
50
+ breadcrumbs,
51
+ back,
52
+ }: SiteHeaderProps) {
41
53
  const mod = useModKeyLabel()
42
54
  const [isStuck, setIsStuck] = React.useState(false)
43
55
 
@@ -51,7 +63,13 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
51
63
  return (
52
64
  <div
53
65
  className={cn(
54
- "sticky top-0 z-60 transition-colors",
66
+ // Sticky page chrome sits BELOW every Radix overlay (DropdownMenu /
67
+ // Popover / Select / Dialog / Sheet / Tooltip / Drawer all render at
68
+ // z-50). Previously `z-60` here caused the school/product switcher
69
+ // dropdown to open behind the breadcrumb. `z-30` keeps the header
70
+ // above page content (charts, tables, scrolled rows) but below
71
+ // floating overlays.
72
+ "sticky top-0 z-30 transition-colors",
55
73
  isStuck ? "bg-sidebar border-b border-border" : "bg-transparent",
56
74
  )}
57
75
  >
@@ -78,29 +96,16 @@ export function SiteHeader({ title = "Dashboard", breadcrumbs }: SiteHeaderProps
78
96
  className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-auto"
79
97
  />
80
98
 
81
- {/* Breadcrumb trail */}
82
- <nav aria-label="Breadcrumb" className="flex items-center gap-1.5 min-w-0 overflow-hidden">
83
- {breadcrumbs?.map((crumb, i) => (
84
- <span key={i} className="flex items-center gap-1.5 shrink-0">
85
- {crumb.href ? (
86
- <Link
87
- href={crumb.href}
88
- className="font-sans text-sm text-muted-foreground hover:text-interactive-hover-foreground transition-colors tracking-normal"
89
- >
90
- {crumb.label}
91
- </Link>
92
- ) : (
93
- <span className="font-sans text-sm text-muted-foreground tracking-normal">
94
- {crumb.label}
95
- </span>
96
- )}
97
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground/50" aria-hidden="true" />
98
- </span>
99
- ))}
100
- <span className="font-sans text-sm font-medium text-foreground tracking-normal truncate">
101
- {title}
102
- </span>
103
- </nav>
99
+ {back ? (
100
+ <PageBreadcrumbBack {...back} className="min-w-0 flex-1" />
101
+ ) : (
102
+ <PageBreadcrumbTrail
103
+ variant="header"
104
+ items={breadcrumbs}
105
+ currentPage={title}
106
+ className="flex-1"
107
+ />
108
+ )}
104
109
 
105
110
  <div className="ml-auto shrink-0">
106
111
  <AskLeoToggle />
@@ -3,45 +3,40 @@
3
3
  import Link from "next/link"
4
4
  import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
5
5
  import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
6
+ import { DataRowList } from "@/components/data-views/data-row-list"
6
7
 
7
8
  export function SitesListView({ rows }: { rows: SiteDirectoryRow[] }) {
8
- if (rows.length === 0) {
9
- return (
10
- <div className="px-4 py-16 text-center lg:px-6">
11
- <p className="text-sm text-muted-foreground">No sites match your search.</p>
12
- </div>
13
- )
14
- }
15
-
16
9
  return (
17
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
18
- {rows.map(site => (
19
- <li key={site.id}>
20
- <Link
21
- href={site.url}
22
- className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
10
+ <DataRowList<SiteDirectoryRow>
11
+ rows={rows}
12
+ getRowId={site => site.id}
13
+ emptyState="No sites match your search."
14
+ ariaLabel="Sites"
15
+ renderRow={site => (
16
+ <Link
17
+ href={site.url}
18
+ className="block rounded-xl text-inherit no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
19
+ >
20
+ <ListPageBoardCard
21
+ layout="row"
22
+ interactive
23
+ rowContainerClassName="flex flex-row items-center gap-3"
24
+ leading={
25
+ <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
26
+ <i className="fa-light fa-hospital text-sm" aria-hidden="true" />
27
+ </span>
28
+ }
29
+ rowEnd={
30
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
31
+ }
23
32
  >
24
- <ListPageBoardCard
25
- layout="row"
26
- interactive
27
- rowContainerClassName="flex flex-row items-center gap-3"
28
- leading={
29
- <span className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-brand/10 text-brand">
30
- <i className="fa-light fa-hospital text-sm" aria-hidden="true" />
31
- </span>
32
- }
33
- rowEnd={
34
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
35
- }
36
- >
37
- <div className="space-y-0.5">
38
- <p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
39
- <p className="truncate text-xs text-muted-foreground">{site.id}</p>
40
- </div>
41
- </ListPageBoardCard>
42
- </Link>
43
- </li>
44
- ))}
45
- </ul>
33
+ <div className="space-y-0.5">
34
+ <p className="truncate text-sm font-semibold text-foreground">{site.name}</p>
35
+ <p className="truncate text-xs text-muted-foreground">{site.id}</p>
36
+ </div>
37
+ </ListPageBoardCard>
38
+ </Link>
39
+ )}
40
+ />
46
41
  )
47
42
  }
@@ -200,6 +200,10 @@ export const SitesTable = React.forwardRef<
200
200
  tableState.setSheetOpen(true)
201
201
  },
202
202
  }),
203
+ // `tableState` is freshly returned each render by useTableState; depending
204
+ // on it would re-create the imperative handle on every render. Only the
205
+ // React setter is needed (and is referentially stable).
206
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
207
  [tableState.setSheetOpen],
204
208
  )
205
209
 
@@ -151,6 +151,24 @@ export function TablePropertiesDrawerButton({
151
151
  sortKey,
152
152
  } = state
153
153
 
154
+ // Sheet is portaled; keep latest handlers so sort/filter/conditional edits are not lost.
155
+ const stateRef = React.useRef(state)
156
+ stateRef.current = state
157
+ const ruleHandlersRef = React.useRef({
158
+ onAddConditionalRule,
159
+ onRemoveConditionalRule,
160
+ onUpdateConditionalRule,
161
+ onDisplayOptionsChange,
162
+ onPaginationChange,
163
+ })
164
+ ruleHandlersRef.current = {
165
+ onAddConditionalRule,
166
+ onRemoveConditionalRule,
167
+ onUpdateConditionalRule,
168
+ onDisplayOptionsChange,
169
+ onPaginationChange,
170
+ }
171
+
154
172
  return (
155
173
  <>
156
174
  {extraActions}
@@ -185,42 +203,42 @@ export function TablePropertiesDrawerButton({
185
203
  rowHeight={rowHeight}
186
204
  onRowHeightChange={setRowHeight}
187
205
  pagination={pagination}
188
- onPaginationChange={onPaginationChange ?? (() => {})}
206
+ onPaginationChange={v => ruleHandlersRef.current.onPaginationChange?.(v)}
189
207
  activeFilters={activeFilters}
190
- onAddFilter={fieldKey => addFilter(fieldKey, true)}
191
- onUpdateFilter={updateFilter}
192
- onRemoveFilter={removeFilter}
193
- getFilterConnector={getConnector}
194
- onToggleFilterConnector={toggleConnector}
208
+ onAddFilter={fieldKey => stateRef.current.addFilter(fieldKey, true)}
209
+ onUpdateFilter={(id, patch) => stateRef.current.updateFilter(id, patch)}
210
+ onRemoveFilter={id => stateRef.current.removeFilter(id)}
211
+ getFilterConnector={leftId => stateRef.current.getConnector(leftId)}
212
+ onToggleFilterConnector={leftId => stateRef.current.toggleConnector(leftId)}
195
213
  filterBarVisible={filterBarVisible}
196
- onFilterBarVisibleChange={setFilterBarVisible}
214
+ onFilterBarVisibleChange={v => stateRef.current.setFilterBarVisible(v)}
197
215
  drawerExpandedFilters={drawerExpandedFilters}
198
- onDrawerExpandedFiltersChange={setDrawerExpandedFilters}
216
+ onDrawerExpandedFiltersChange={stateRef.current.setDrawerExpandedFilters}
199
217
  totalRows={totalRows}
200
218
  filteredRows={rows.length}
201
219
  sortRules={sortRules}
202
- onSortRulesChange={setSortRules}
203
- onAddSortRule={addSortRule}
204
- onRemoveSortRule={removeSortRule}
205
- onToggleSortDir={toggleSortDir}
220
+ onSortRulesChange={rules => stateRef.current.setSortRules(rules)}
221
+ onAddSortRule={fieldKey => stateRef.current.addSortRule(fieldKey)}
222
+ onRemoveSortRule={id => stateRef.current.removeSortRule(id)}
223
+ onToggleSortDir={id => stateRef.current.toggleSortDir(id)}
206
224
  colOrder={colOrder}
207
- onColOrderChange={setColOrder}
225
+ onColOrderChange={order => stateRef.current.setColOrder(order)}
208
226
  hiddenCols={hiddenCols}
209
- onToggleColVisibility={toggleColVisibility}
210
- onMoveCol={moveCol}
227
+ onToggleColVisibility={key => stateRef.current.toggleColVisibility(key)}
228
+ onMoveCol={(key, dir) => stateRef.current.moveCol(key, dir)}
211
229
  groupBy={groupBy}
212
- onGroupByChange={setGroupBy}
230
+ onGroupByChange={key => stateRef.current.setGroupBy(key)}
213
231
  primarySortKey={sortKey}
214
232
  conditionalRules={conditionalRules}
215
- onAddConditionalRule={onAddConditionalRule}
216
- onRemoveConditionalRule={onRemoveConditionalRule}
217
- onUpdateConditionalRule={onUpdateConditionalRule}
233
+ onAddConditionalRule={rule => ruleHandlersRef.current.onAddConditionalRule(rule)}
234
+ onRemoveConditionalRule={id => ruleHandlersRef.current.onRemoveConditionalRule(id)}
235
+ onUpdateConditionalRule={(id, patch) => ruleHandlersRef.current.onUpdateConditionalRule(id, patch)}
218
236
  filterFields={filterFields}
219
237
  lifecycleTabLabel={lifecycleTabLabel}
220
238
  fieldDefinitions={fieldDefinitions}
221
239
  resolveColumnLabel={resolveColumnLabel}
222
240
  displayOptions={displayOptions}
223
- onDisplayOptionsChange={onDisplayOptionsChange}
241
+ onDisplayOptionsChange={patch => ruleHandlersRef.current.onDisplayOptionsChange(patch)}
224
242
  currentView={currentView}
225
243
  onViewChange={onViewChange}
226
244
  boardGroupByColumnOptions={boardGroupByColumnOptions}
@@ -117,6 +117,9 @@ export interface TablePropertiesDrawerProps {
117
117
 
118
118
  type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
119
119
 
120
+ /** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
121
+ const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
122
+
120
123
  export function TablePropertiesDrawer({
121
124
  open,
122
125
  onOpenChange,
@@ -243,14 +246,14 @@ export function TablePropertiesDrawer({
243
246
  : "—"
244
247
 
245
248
  return (
246
- <Sheet open={open} onOpenChange={onOpenChange}>
249
+ <Sheet open={open} onOpenChange={onOpenChange} modal={false}>
247
250
  <SheetContent
248
251
  side="right"
249
252
  showCloseButton={false}
250
253
  showOverlay={false}
251
254
  // w-[min(20rem,calc(100vw-1rem))]: cap to viewport width - 1rem at narrow/zoomed viewports
252
255
  // 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"
256
+ className="z-[80] w-[min(20rem,calc(100vw-1rem))] p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
254
257
  style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100svh - 1rem)" }}
255
258
  >
256
259
 
@@ -462,7 +465,7 @@ export function TablePropertiesDrawer({
462
465
  >
463
466
  <SelectValue />
464
467
  </SelectTrigger>
465
- <SelectContent align="end">
468
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
466
469
  {boardGroupByColumnOptions.map(o => (
467
470
  <SelectItem key={o.key} value={o.key}>
468
471
  {o.label}
@@ -533,7 +536,7 @@ export function TablePropertiesDrawer({
533
536
  <SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
534
537
  <SelectValue />
535
538
  </SelectTrigger>
536
- <SelectContent align="end">
539
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
537
540
  <SelectItem value="1">1 line</SelectItem>
538
541
  <SelectItem value="2">2 lines</SelectItem>
539
542
  <SelectItem value="3">3 lines</SelectItem>
@@ -659,7 +662,7 @@ export function TablePropertiesDrawer({
659
662
  {[
660
663
  { icon: "fa-circle-1", text: "Click \"Add filter\" below" },
661
664
  { icon: "fa-circle-2", text: "Choose a field to filter by" },
662
- { icon: "fa-circle-3", text: "Pick a condition and value" },
665
+ { icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
663
666
  ].map(step => (
664
667
  <div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
665
668
  <i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
@@ -726,7 +729,7 @@ export function TablePropertiesDrawer({
726
729
 
727
730
  {/* Add filter + Remove all */}
728
731
  <div className="flex items-center gap-2 pt-2">
729
- <DropdownMenu>
732
+ <DropdownMenu modal={false}>
730
733
  <DropdownMenuTrigger asChild>
731
734
  <Button
732
735
  type="button"
@@ -737,11 +740,11 @@ export function TablePropertiesDrawer({
737
740
  Add filter
738
741
  </Button>
739
742
  </DropdownMenuTrigger>
740
- <DropdownMenuContent align="start">
743
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
741
744
  <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
742
745
  <DropdownMenuSeparator />
743
746
  {filterFields.map(f => (
744
- <DropdownMenuItem key={f.key} onClick={() => onAddFilter(f.key)}>
747
+ <DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
745
748
  <i className={`fa-light ${f.icon}`} aria-hidden="true" />
746
749
  {f.label}
747
750
  </DropdownMenuItem>
@@ -833,7 +836,7 @@ export function TablePropertiesDrawer({
833
836
 
834
837
  {/* Add sort + Remove all */}
835
838
  <div className="flex items-center gap-2 pt-2">
836
- <DropdownMenu>
839
+ <DropdownMenu modal={false}>
837
840
  <DropdownMenuTrigger asChild>
838
841
  <Button
839
842
  type="button"
@@ -844,11 +847,11 @@ export function TablePropertiesDrawer({
844
847
  Add sort
845
848
  </Button>
846
849
  </DropdownMenuTrigger>
847
- <DropdownMenuContent align="start">
850
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
848
851
  <DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
849
852
  <DropdownMenuSeparator />
850
853
  {sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
851
- <DropdownMenuItem key={col.key} onClick={() => onAddSortRule(col.key)}>
854
+ <DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
852
855
  <i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
853
856
  {col.label}
854
857
  </DropdownMenuItem>
@@ -1052,7 +1055,7 @@ function ConditionalRulesPanel({
1052
1055
  )}
1053
1056
 
1054
1057
  <div className="flex items-center gap-2 pt-2">
1055
- <DropdownMenu>
1058
+ <DropdownMenu modal={false}>
1056
1059
  <DropdownMenuTrigger asChild>
1057
1060
  <Button
1058
1061
  type="button"
@@ -1063,13 +1066,13 @@ function ConditionalRulesPanel({
1063
1066
  Add rule
1064
1067
  </Button>
1065
1068
  </DropdownMenuTrigger>
1066
- <DropdownMenuContent align="start">
1069
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
1067
1070
  <DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
1068
1071
  <DropdownMenuSeparator />
1069
1072
  {filterFields.map(f => (
1070
1073
  <DropdownMenuItem
1071
1074
  key={f.key}
1072
- onClick={() => onAdd({
1075
+ onSelect={() => onAdd({
1073
1076
  fieldKey: f.key,
1074
1077
  operator: f.operators[0],
1075
1078
  values: [],
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as DataListClient).
4
+ * Team page — primary list template: ListPageTemplate + KeyMetrics + TeamTable (same composition as PlacementsClient).
5
5
  * Imports from `@/components/data-views` for shared list-page + view types.
6
6
  */
7
7
 
@@ -2,11 +2,13 @@
2
2
 
3
3
  /**
4
4
  * TeamListView — full-width rows for team roster (same data as DataTable / board).
5
+ * Shell from generic `DataRowList`; row body stays team-specific (avatar,
6
+ * name, role, email, status badge).
5
7
  */
6
8
 
7
- import * as React from "react"
8
9
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
9
10
  import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
11
+ import { DataRowList } from "@/components/data-views/data-row-list"
10
12
  import {
11
13
  TEAM_MEMBER_STATUS_BADGE_CLASS,
12
14
  TEAM_MEMBER_STATUS_ICON,
@@ -14,42 +16,6 @@ import {
14
16
  } from "@/lib/list-status-badges"
15
17
  import type { TeamMember } from "@/lib/mock/team"
16
18
 
17
- function TeamListRow({
18
- member,
19
- onRowActivate,
20
- }: {
21
- member: TeamMember
22
- onRowActivate?: (member: TeamMember) => void
23
- }) {
24
- return (
25
- <li>
26
- <ListPageBoardCard
27
- layout="row"
28
- rowContainerClassName="flex flex-row items-center gap-3"
29
- onClick={onRowActivate ? () => onRowActivate(member) : undefined}
30
- leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
31
- rowEnd={
32
- <div className="flex shrink-0 items-center gap-2">
33
- <ListHubStatusBadge
34
- surface="board"
35
- label={TEAM_MEMBER_STATUS_LABEL[member.status]}
36
- tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
37
- icon={TEAM_MEMBER_STATUS_ICON[member.status]}
38
- />
39
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
40
- </div>
41
- }
42
- >
43
- <div className="space-y-0.5">
44
- <p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
45
- <p className="text-xs text-muted-foreground">{member.role}</p>
46
- <p className="truncate text-xs text-muted-foreground">{member.email}</p>
47
- </div>
48
- </ListPageBoardCard>
49
- </li>
50
- )
51
- }
52
-
53
19
  export function TeamListView({
54
20
  members,
55
21
  onRowActivate,
@@ -57,19 +23,37 @@ export function TeamListView({
57
23
  members: TeamMember[]
58
24
  onRowActivate?: (member: TeamMember) => void
59
25
  }) {
60
- if (members.length === 0) {
61
- return (
62
- <div className="px-4 py-16 text-center lg:px-6">
63
- <p className="text-sm text-muted-foreground">No team members match your filters.</p>
64
- </div>
65
- )
66
- }
67
-
68
26
  return (
69
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
70
- {members.map(m => (
71
- <TeamListRow key={m.id} member={m} onRowActivate={onRowActivate} />
72
- ))}
73
- </ul>
27
+ <DataRowList<TeamMember>
28
+ rows={members}
29
+ getRowId={m => m.id}
30
+ emptyState="No team members match your filters."
31
+ ariaLabel="Team members"
32
+ renderRow={member => (
33
+ <ListPageBoardCard
34
+ layout="row"
35
+ rowContainerClassName="flex flex-row items-center gap-3"
36
+ onClick={onRowActivate ? () => onRowActivate(member) : undefined}
37
+ leading={<ListPageBoardCardAvatar initials={member.initials} className="size-9" />}
38
+ rowEnd={
39
+ <div className="flex shrink-0 items-center gap-2">
40
+ <ListHubStatusBadge
41
+ surface="board"
42
+ label={TEAM_MEMBER_STATUS_LABEL[member.status]}
43
+ tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
44
+ icon={TEAM_MEMBER_STATUS_ICON[member.status]}
45
+ />
46
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
47
+ </div>
48
+ }
49
+ >
50
+ <div className="space-y-0.5">
51
+ <p className="truncate text-sm font-semibold text-foreground">{member.name}</p>
52
+ <p className="text-xs text-muted-foreground">{member.role}</p>
53
+ <p className="truncate text-xs text-muted-foreground">{member.email}</p>
54
+ </div>
55
+ </ListPageBoardCard>
56
+ )}
57
+ />
74
58
  )
75
59
  }
@@ -13,6 +13,7 @@ import {
13
13
  TEAM_MEMBER_STATUS_ICON,
14
14
  TEAM_MEMBER_STATUS_LABEL,
15
15
  } from "@/lib/list-status-badges"
16
+ import { mailtoHref } from "@/lib/mailto"
16
17
  import type { TeamMember } from "@/lib/mock/team"
17
18
  import { DataTable, DataTableToolbar } from "@/components/data-table"
18
19
  import {
@@ -154,7 +155,7 @@ function TeamFinderDetail({
154
155
  <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
155
156
  </dt>
156
157
  <dd className="text-[13px]">
157
- <a href={`mailto:${member.email}`} className="text-interactive-foreground hover:underline">{member.email}</a>
158
+ <a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
158
159
  </dd>
159
160
  </div>
160
161
  <div className="flex flex-col gap-0.5">
@@ -277,7 +278,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
277
278
  operators: ["contains", "not_contains"],
278
279
  },
279
280
  cell: row => (
280
- <a href={`mailto:${row.email}`} className="text-sm text-primary hover:underline truncate block">
281
+ <a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
281
282
  {row.email}
282
283
  </a>
283
284
  ),
@@ -341,7 +342,7 @@ function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
341
342
  </Button>
342
343
  </DropdownMenuTrigger>
343
344
  <DropdownMenuContent align="end">
344
- <DropdownMenuItem onClick={() => window.open(`mailto:${row.email}`)}>
345
+ <DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
345
346
  <i className="fa-light fa-envelope" aria-hidden="true" />
346
347
  Email
347
348
  </DropdownMenuItem>
@@ -501,6 +502,10 @@ export const TeamTable = React.forwardRef<
501
502
  openPropertiesDrawer: () => {
502
503
  tableState.setSheetOpen(true)
503
504
  },
505
+ // `tableState` is freshly returned each render by useTableState; depending on
506
+ // it would re-create the imperative handle on every render. Only the React
507
+ // setter is needed (and is referentially stable).
508
+ // eslint-disable-next-line react-hooks/exhaustive-deps
504
509
  }), [tableState.setSheetOpen])
505
510
 
506
511
  const teamPanelFinderGroups = React.useMemo(
@@ -47,7 +47,7 @@ import {
47
47
  Shortcut,
48
48
  } from "@/components/ui/dropdown-menu"
49
49
  import type { DataListViewType } from "@/lib/data-list-view"
50
- import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
50
+ import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "@/lib/data-list-view"
51
51
  import {
52
52
  createListPageEditViewHandler,
53
53
  type OpenTablePropertiesHandle,
@@ -273,13 +273,16 @@ export function ListPageTemplate({
273
273
 
274
274
  return (
275
275
  <>
276
- {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => (
277
- <Shortcut
278
- key={v.type}
279
- keys={`⌘⇧${i + 1}`}
280
- onInvoke={() => addView(v.type)}
281
- />
282
- ))}
276
+ {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => {
277
+ const keys = dataListViewAddShortcut(i)
278
+ return keys ? (
279
+ <Shortcut
280
+ key={v.type}
281
+ keys={keys}
282
+ onInvoke={() => addView(v.type)}
283
+ />
284
+ ) : null
285
+ })}
283
286
  {activeTab && !hideViewsToolbar && (
284
287
  <>
285
288
  <Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
@@ -480,7 +483,7 @@ export function ListPageTemplate({
480
483
  {VIEW_TYPES.map((v, i) => (
481
484
  <DropdownMenuItem
482
485
  key={v.type}
483
- shortcut={i < 9 ? `⌘⇧${i + 1}` : undefined}
486
+ shortcut={dataListViewAddShortcut(i)}
484
487
  onSelect={() => addView(v.type)}
485
488
  >
486
489
  <i className={`fa-light ${v.icon}`} aria-hidden="true" />
@@ -15,7 +15,7 @@ export interface NestedSecondaryPanelShellProps {
15
15
 
16
16
  /**
17
17
  * Shared chrome for a nested hub rail — full width vs icon rail.
18
- * Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
18
+ * Fill uses `--secondary-panel-bg` one step lighter than `--sidebar` (elevation 1).
19
19
  */
20
20
  export function NestedSecondaryPanelShell({
21
21
  open,
@@ -34,10 +34,16 @@ export function NestedSecondaryPanelShell({
34
34
  "transition-[width,margin,opacity] duration-200 ease-linear",
35
35
  open
36
36
  ? cn(
37
- "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
37
+ // Match the primary sidebar: fill the full viewport height
38
+ // (minus our 0.5rem top + 0.5rem bottom margin from `m-2` →
39
+ // 1rem on desktop where the panel is `md:sticky md:top-2`;
40
+ // 2rem on mobile where the panel scrolls inline and we leave
41
+ // a little more breathing room). No upper cap so tall screens
42
+ // get a fully-extended rail.
43
+ "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
38
44
  compact
39
- ? "w-12 min-w-12 max-w-12 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]"
40
- : "w-64 min-w-64 max-w-64 h-[min(calc(100svh-2rem),800px)] md:h-[min(calc(100svh-1rem),800px)]",
45
+ ? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
46
+ : "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
41
47
  )
42
48
  : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none",
43
49
  className,