@exxatdesignux/ui 0.2.14 → 0.2.16

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 (56) hide show
  1. package/CHANGELOG.md +20 -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 +3 -0
  4. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +3 -1
  5. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  6. package/package.json +1 -1
  7. package/src/components/ui/dropdown-menu.tsx +2 -0
  8. package/src/components/ui/popover.tsx +2 -2
  9. package/src/components/ui/select.tsx +1 -1
  10. package/src/components/ui/tooltip.tsx +7 -1
  11. package/src/globals.css +27 -2
  12. package/src/theme.css +4 -2
  13. package/template/AGENTS.md +6 -4
  14. package/template/app/(app)/question-bank/layout.tsx +11 -4
  15. package/template/app/globals.css +34 -2
  16. package/template/components/app-sidebar.tsx +89 -41
  17. package/template/components/ask-leo-sidebar.tsx +1 -2
  18. package/template/components/compliance-board-view.tsx +11 -3
  19. package/template/components/compliance-list-view.tsx +16 -3
  20. package/template/components/compliance-table.tsx +5 -1
  21. package/template/components/data-table/index.tsx +25 -11
  22. package/template/components/data-views/finder-panel-view.tsx +2 -2
  23. package/template/components/data-views/index.ts +19 -0
  24. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  25. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  26. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  27. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  28. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  29. package/template/components/exxat-product-logo.tsx +11 -72
  30. package/template/components/folder-details-shell.tsx +1 -1
  31. package/template/components/hub-tree-panel-view.tsx +88 -80
  32. package/template/components/key-metrics.tsx +50 -13
  33. package/template/components/page-header.tsx +19 -10
  34. package/template/components/product-switcher.tsx +1 -4
  35. package/template/components/question-bank-board-view.tsx +11 -2
  36. package/template/components/question-bank-client.tsx +111 -69
  37. package/template/components/question-bank-list-view.tsx +12 -1
  38. package/template/components/question-bank-page-header.tsx +18 -2
  39. package/template/components/question-bank-secondary-nav.tsx +12 -225
  40. package/template/components/question-bank-table.tsx +6 -1
  41. package/template/components/secondary-panel.tsx +1 -1
  42. package/template/components/site-header.tsx +21 -2
  43. package/template/components/team-board-view.tsx +11 -3
  44. package/template/components/team-list-view.tsx +16 -3
  45. package/template/components/team-table.tsx +6 -2
  46. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  47. package/template/components/templates/list-page.tsx +1 -3
  48. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -4
  49. package/template/docs/collaboration-access-pattern.md +2 -0
  50. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  51. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  52. package/template/lib/mock/navigation.tsx +30 -1
  53. package/template/lib/question-bank-nav.ts +26 -0
  54. package/template/package.json +3 -3
  55. package/template/components/command-menu-01.tsx +0 -133
  56. package/template/components/command-menu-02.tsx +0 -386
@@ -52,6 +52,7 @@ import {
52
52
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
53
53
  import { Badge } from "@/components/ui/badge"
54
54
  import { StatusBadge } from "@/components/ui/status-badge"
55
+ import { Separator } from "@/components/ui/separator"
55
56
  import {
56
57
  Tooltip,
57
58
  TooltipContent,
@@ -108,7 +109,8 @@ function normalizedLocationHash(locationHash: string): string {
108
109
  /**
109
110
  * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
110
111
  * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
111
- * and require an empty hash for the “default” row (`/settings` with no `#`).
112
+ * in each `href` those rows use the `frag !== null` branch below.
113
+ * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
112
114
  */
113
115
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
114
116
  const pathOnly = navUrlPath(url)
@@ -129,7 +131,8 @@ function isNavActive(pathname: string, url: string, locationHash = ""): boolean
129
131
  }
130
132
 
131
133
  if (pathOnly === "/") return pathname === "/" && h === ""
132
- if (pathname === pathOnly) return h === ""
134
+ /** Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash). */
135
+ if (pathname === pathOnly) return true
133
136
  // Design system library — active on hub and detail routes.
134
137
  if (pathOnly === "/library") {
135
138
  return pathname.startsWith("/library/")
@@ -165,6 +168,16 @@ function isCollapsibleChildActive(
165
168
 
166
169
  if (!isNavActive(pathname, child.url, locationHash)) return false
167
170
 
171
+ /** Hub entry (`/question-bank`) must not stay “active” on `/question-bank/library` etc. */
172
+ if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
173
+ const hubPath = navUrlPath(parent.url)
174
+ if (hubPath) {
175
+ const normalized =
176
+ pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname
177
+ if (normalized !== hubPath) return false
178
+ }
179
+ }
180
+
168
181
  const urls = children.map(c => c.url)
169
182
  const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
170
183
  if (allSameUrl) {
@@ -176,6 +189,32 @@ function isCollapsibleChildActive(
176
189
  return true
177
190
  }
178
191
 
192
+ /**
193
+ * “Selected” styling on a collapsible **parent** row — not the same as “a descendant route is open”.
194
+ * When a child row is the current destination (e.g. Library on `/question-bank/library`), the parent
195
+ * should stay visually neutral while the child carries `data-active`. Only highlight the parent when
196
+ * the active child is the hub row whose `href` matches the parent (e.g. Question hub on `/question-bank`),
197
+ * or when no child matches but the parent URL still matches (edge routes).
198
+ */
199
+ function isCollapsibleParentMenuButtonActive(
200
+ pathname: string,
201
+ item: NavLinkItem,
202
+ locationHash: string,
203
+ ): boolean {
204
+ const children = item.children
205
+ if (!children?.length) return isNavActive(pathname, item.url, locationHash)
206
+
207
+ const activeChildren = children.filter(c =>
208
+ isCollapsibleChildActive(pathname, item, c, locationHash),
209
+ )
210
+ if (activeChildren.length === 0) {
211
+ return isNavActive(pathname, item.url, locationHash)
212
+ }
213
+ if (activeChildren.length !== 1) return false
214
+ const [child] = activeChildren
215
+ return navUrlPath(child.url) === navUrlPath(item.url)
216
+ }
217
+
179
218
  /** Accessible suffix for sidebar badges (badge is rendered outside the link node). */
180
219
  function badgeAccessibleSuffix(badge: number | string): string {
181
220
  if (typeof badge === "number") return `${badge} items`
@@ -183,30 +222,40 @@ function badgeAccessibleSuffix(badge: number | string): string {
183
222
  }
184
223
 
185
224
  /** Child row for expandable nav items — shared by inline sub-menu and collapsed-rail popover. */
186
- function SidebarNavChildLink({
187
- parent,
188
- child,
189
- pathname,
190
- locationHash,
191
- onNavigate,
192
- linkClassName,
193
- }: {
194
- parent: NavLinkItem
195
- child: NavLinkItem
196
- pathname: string
197
- locationHash: string
198
- onNavigate?: () => void
199
- /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
200
- linkClassName?: string
201
- }) {
225
+ const SidebarNavChildLink = React.forwardRef<
226
+ HTMLAnchorElement,
227
+ {
228
+ parent: NavLinkItem
229
+ child: NavLinkItem
230
+ pathname: string
231
+ locationHash: string
232
+ onNavigate?: () => void
233
+ /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
234
+ linkClassName?: string
235
+ } & Omit<React.ComponentPropsWithoutRef<typeof Link>, "href">
236
+ >(function SidebarNavChildLink(
237
+ {
238
+ parent,
239
+ child,
240
+ pathname,
241
+ locationHash,
242
+ onNavigate,
243
+ linkClassName,
244
+ className: incomingClassName,
245
+ onClick,
246
+ ...linkRest
247
+ },
248
+ ref,
249
+ ) {
202
250
  const { openPanel } = useSecondaryPanel()
203
251
  const childActive = isCollapsibleChildActive(pathname, parent, child, locationHash)
204
252
  const childPath = navUrlPath(child.url)
205
253
 
206
254
  return (
207
255
  <Link
256
+ ref={ref}
208
257
  href={child.url}
209
- className={cn("flex min-w-0 items-center gap-2", linkClassName)}
258
+ className={cn("flex min-w-0 items-center gap-2", linkClassName, incomingClassName)}
210
259
  aria-current={childActive ? "page" : undefined}
211
260
  onClick={e => {
212
261
  onNavigate?.()
@@ -218,15 +267,18 @@ function SidebarNavChildLink({
218
267
  e.preventDefault()
219
268
  openPanel(parent.secondaryPanel)
220
269
  }
270
+ onClick?.(e)
221
271
  }}
272
+ {...linkRest}
222
273
  >
223
274
  <span className="size-4 shrink-0 inline-flex items-center justify-center" aria-hidden="true">
224
- {child.icon}
275
+ {childActive && child.iconActive ? child.iconActive : child.icon}
225
276
  </span>
226
277
  <span className="min-w-0 flex-1 truncate">{child.title}</span>
227
278
  </Link>
228
279
  )
229
- }
280
+ })
281
+ SidebarNavChildLink.displayName = "SidebarNavChildLink"
230
282
 
231
283
  /**
232
284
  * CollapsibleNavItem — isolated component so each collapsible has its own
@@ -235,19 +287,17 @@ function SidebarNavChildLink({
235
287
  * server (SSR) vs the client (router not yet available).
236
288
  */
237
289
  function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
238
- const locationHash = useLocationHash()
239
- const isActive = isNavActive(pathname, item.url, locationHash)
290
+ const locationHash = useLocationHash()
240
291
  const isAnyChildActive =
241
292
  item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
293
+ const parentMenuButtonActive = isCollapsibleParentMenuButtonActive(pathname, item, locationHash)
242
294
  const { state, isMobile } = useSidebar()
243
- const { openPanel } = useSecondaryPanel()
244
295
  const [open, setOpen] = React.useState(false)
245
296
  const [flyoutOpen, setFlyoutOpen] = React.useState(false)
246
297
  const flyoutTitleId = React.useId()
247
298
  const iconRailCollapsed = state === "collapsed" && !isMobile
248
- const showActiveStyle = isActive || isAnyChildActive
249
299
  const triggerIcon =
250
- showActiveStyle && item.iconActive ? item.iconActive : item.icon
300
+ parentMenuButtonActive && item.iconActive ? item.iconActive : item.icon
251
301
 
252
302
  React.useEffect(() => {
253
303
  setOpen(isAnyChildActive)
@@ -267,23 +317,20 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
267
317
  open={flyoutOpen}
268
318
  onOpenChange={next => {
269
319
  setFlyoutOpen(next)
270
- if (next && item.secondaryPanel) {
271
- openPanel(item.secondaryPanel)
272
- }
273
320
  }}
274
321
  >
275
322
  <Tooltip>
276
323
  <TooltipTrigger asChild>
277
324
  <PopoverTrigger asChild>
278
325
  <SidebarMenuButton
279
- isActive={showActiveStyle}
326
+ isActive={parentMenuButtonActive}
280
327
  aria-haspopup="dialog"
281
328
  aria-label={`${item.title} — open subpages`}
282
329
  >
283
330
  <span
284
331
  className={cn(
285
332
  "size-4 shrink-0 flex items-center justify-center",
286
- showActiveStyle &&
333
+ parentMenuButtonActive &&
287
334
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
288
335
  )}
289
336
  aria-hidden="true"
@@ -341,9 +388,6 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
341
388
  open={open}
342
389
  onOpenChange={next => {
343
390
  setOpen(next)
344
- if (next && item.secondaryPanel) {
345
- openPanel(item.secondaryPanel)
346
- }
347
391
  }}
348
392
  asChild
349
393
  >
@@ -351,12 +395,12 @@ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: s
351
395
  <Tooltip>
352
396
  <TooltipTrigger asChild>
353
397
  <CollapsibleTrigger asChild>
354
- <SidebarMenuButton isActive={showActiveStyle}>
398
+ <SidebarMenuButton isActive={parentMenuButtonActive}>
355
399
  <span
356
- key={showActiveStyle ? "active" : "idle"}
400
+ key={parentMenuButtonActive ? "active" : "idle"}
357
401
  className={cn(
358
402
  "size-4 shrink-0 flex items-center justify-center",
359
- showActiveStyle &&
403
+ parentMenuButtonActive &&
360
404
  "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
361
405
  )}
362
406
  aria-hidden="true"
@@ -769,10 +813,7 @@ function ProductLogoButton() {
769
813
  >
770
814
  {iconRail ? (
771
815
  <span className="flex size-8 shrink-0 items-center justify-center">
772
- <ExxatProductMark
773
- product={current.id}
774
- className="size-7 max-h-none"
775
- />
816
+ <ExxatProductMark product={current.id} className="size-7" />
776
817
  </span>
777
818
  ) : (
778
819
  <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
@@ -901,6 +942,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
901
942
  <ProductLogoButton />
902
943
  </SidebarMenuItem>
903
944
  </SidebarMenu>
945
+ <div className="flex w-full justify-center px-2">
946
+ <Separator
947
+ orientation="horizontal"
948
+ decorative
949
+ className="my-1.5 h-px w-full max-w-none shrink-0 bg-sidebar-border group-data-[collapsible=icon]:w-8"
950
+ />
951
+ </div>
904
952
  <TeamSwitcher />
905
953
  </SidebarHeaderStack>
906
954
  </SidebarHeader>
@@ -286,8 +286,7 @@ export function AskLeoSidebar() {
286
286
  style={
287
287
  open
288
288
  ? {
289
- background:
290
- "linear-gradient(180deg, color-mix(in oklch, var(--brand-color) 4%, var(--background)) 0%, color-mix(in oklch, var(--brand-color) 8%, var(--background)) 100%)",
289
+ background: "var(--leo-surface-gradient)",
291
290
  }
292
291
  : undefined
293
292
  }
@@ -79,10 +79,16 @@ function useComplianceBoardModel(rows: ComplianceItem[], groupByColumnKey: strin
79
79
  }, [rows, groupByColumnKey])
80
80
  }
81
81
 
82
- function ComplianceBoardCard({ row }: { row: ComplianceItem }) {
82
+ function ComplianceBoardCard({
83
+ row,
84
+ onRowActivate,
85
+ }: {
86
+ row: ComplianceItem
87
+ onRowActivate?: (row: ComplianceItem) => void
88
+ }) {
83
89
  const ownerInitials = initialsFromDisplayName(row.owner)
84
90
  return (
85
- <ListPageBoardCard className="w-full">
91
+ <ListPageBoardCard className="w-full" onClick={onRowActivate ? () => onRowActivate(row) : undefined}>
86
92
  <ListPageBoardCardHeader>
87
93
  <ListPageBoardCardTitleRow
88
94
  title={row.title}
@@ -114,9 +120,11 @@ export const COMPLIANCE_BOARD_GROUP_OPTIONS = [
114
120
  export function ComplianceBoardView({
115
121
  rows,
116
122
  groupByColumnKey,
123
+ onRowActivate,
117
124
  }: {
118
125
  rows: ComplianceItem[]
119
126
  groupByColumnKey: string
127
+ onRowActivate?: (row: ComplianceItem) => void
120
128
  }) {
121
129
  const key = groupByColumnKey === "category" ? "category" : "status"
122
130
  const { columns, badgeMap } = useComplianceBoardModel(rows, key)
@@ -128,7 +136,7 @@ export function ComplianceBoardView({
128
136
  getRowKey={r => r.id}
129
137
  columnCountBadgeClassName={badgeMap}
130
138
  emptyColumnLabel="No items"
131
- renderCard={row => <ComplianceBoardCard row={row} />}
139
+ renderCard={row => <ComplianceBoardCard row={row} onRowActivate={onRowActivate} />}
132
140
  />
133
141
  )
134
142
  }
@@ -10,12 +10,19 @@ import {
10
10
  } from "@/lib/list-status-badges"
11
11
  import type { ComplianceItem } from "@/lib/mock/compliance"
12
12
 
13
- function ComplianceListRow({ row }: { row: ComplianceItem }) {
13
+ function ComplianceListRow({
14
+ row,
15
+ onRowActivate,
16
+ }: {
17
+ row: ComplianceItem
18
+ onRowActivate?: (row: ComplianceItem) => void
19
+ }) {
14
20
  return (
15
21
  <li>
16
22
  <ListPageBoardCard
17
23
  layout="row"
18
24
  rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
25
+ onClick={onRowActivate ? () => onRowActivate(row) : undefined}
19
26
  rowEnd={
20
27
  <div className="flex shrink-0 items-center gap-2">
21
28
  <ListHubStatusBadge
@@ -40,7 +47,13 @@ function ComplianceListRow({ row }: { row: ComplianceItem }) {
40
47
  )
41
48
  }
42
49
 
43
- export function ComplianceListView({ rows }: { rows: ComplianceItem[] }) {
50
+ export function ComplianceListView({
51
+ rows,
52
+ onRowActivate,
53
+ }: {
54
+ rows: ComplianceItem[]
55
+ onRowActivate?: (row: ComplianceItem) => void
56
+ }) {
44
57
  if (rows.length === 0) {
45
58
  return (
46
59
  <div className="px-4 py-16 text-center lg:px-6">
@@ -52,7 +65,7 @@ export function ComplianceListView({ rows }: { rows: ComplianceItem[] }) {
52
65
  return (
53
66
  <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
54
67
  {rows.map(row => (
55
- <ComplianceListRow key={row.id} row={row} />
68
+ <ComplianceListRow key={row.id} row={row} onRowActivate={onRowActivate} />
56
69
  ))}
57
70
  </ul>
58
71
  )
@@ -509,7 +509,10 @@ export const ComplianceTable = React.forwardRef<
509
509
  return (
510
510
  <div className="flex min-h-0 flex-1 flex-col">
511
511
  {sharedToolbar}
512
- <ComplianceListView rows={tableState.rows as ComplianceItem[]} />
512
+ <ComplianceListView
513
+ rows={tableState.rows as ComplianceItem[]}
514
+ onRowActivate={row => tableState.toggleRow(row.id)}
515
+ />
513
516
  </div>
514
517
  )
515
518
  }
@@ -521,6 +524,7 @@ export const ComplianceTable = React.forwardRef<
521
524
  <ComplianceBoardView
522
525
  rows={tableState.rows as ComplianceItem[]}
523
526
  groupByColumnKey={complianceBoardGroupKey}
527
+ onRowActivate={row => tableState.toggleRow(row.id)}
524
528
  />
525
529
  </div>
526
530
  )
@@ -214,7 +214,7 @@ function FilterPillBase<TData>({
214
214
  <PopoverAnchor asChild>
215
215
  <div
216
216
  className={cn(
217
- "inline-flex items-center rounded border text-xs transition-colors",
217
+ "inline-flex cursor-pointer items-center rounded border text-xs transition-colors",
218
218
  isActive ? "border-brand/45 bg-brand/10" : "border-input bg-background"
219
219
  )}
220
220
  >
@@ -222,7 +222,7 @@ function FilterPillBase<TData>({
222
222
  <button
223
223
  type="button"
224
224
  className={cn(
225
- "inline-flex items-center gap-1 h-6 pl-2 pr-1.5 rounded-l transition-colors",
225
+ "inline-flex cursor-pointer items-center gap-1 h-6 pl-2 pr-1.5 rounded-l transition-colors",
226
226
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
227
227
  isActive ? "hover:bg-brand/15" : "hover:bg-interactive-hover",
228
228
  )}
@@ -240,7 +240,7 @@ function FilterPillBase<TData>({
240
240
  aria-label={`Remove ${col.label} filter`}
241
241
  onClick={() => onRemove(filter.id)}
242
242
  className={cn(
243
- "inline-flex items-center justify-center h-6 w-5 rounded-r transition-colors",
243
+ "inline-flex cursor-pointer items-center justify-center h-6 w-5 rounded-r transition-colors",
244
244
  "text-muted-foreground hover:text-destructive",
245
245
  isActive ? "hover:bg-brand/15" : "hover:bg-interactive-hover",
246
246
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
@@ -496,7 +496,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
496
496
  <DropdownMenu>
497
497
  <DropdownMenuTrigger asChild>
498
498
  <button type="button"
499
- className="inline-flex items-center gap-1 h-6 px-2 rounded text-xs text-muted-foreground hover:text-interactive-hover-foreground border border-dashed border-input/70 hover:border-input hover:bg-interactive-hover-subtle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
499
+ className="inline-flex cursor-pointer items-center gap-1 h-6 px-2 rounded text-xs text-muted-foreground hover:text-interactive-hover-foreground border border-dashed border-input/70 hover:border-input hover:bg-interactive-hover-subtle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
500
500
  >
501
501
  <i className="fa-light fa-plus text-xs" aria-hidden="true" />
502
502
  Add filter
@@ -518,7 +518,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
518
518
  <button
519
519
  type="button"
520
520
  onClick={() => setActiveFilters([])}
521
- className="text-xs text-muted-foreground hover:text-interactive-hover-foreground transition-colors px-1"
521
+ className="cursor-pointer text-xs text-muted-foreground hover:text-interactive-hover-foreground transition-colors px-1"
522
522
  >
523
523
  Clear all
524
524
  </button>
@@ -556,7 +556,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
556
556
  type="button"
557
557
  aria-label="Clear search"
558
558
  onClick={() => setSearch("")}
559
- className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
559
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex cursor-pointer size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:text-interactive-hover-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
560
560
  >
561
561
  <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
562
562
  </button>
@@ -568,7 +568,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
568
568
  <TooltipTrigger asChild>
569
569
  <button type="button" aria-label="Search"
570
570
  onClick={() => { setSearchOpen(true); setTimeout(() => searchRef.current?.focus(), 10) }}
571
- className="inline-flex shrink-0 items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
571
+ className="inline-flex shrink-0 cursor-pointer items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
572
572
  >
573
573
  <i className="fa-light fa-magnifying-glass text-[13px]" aria-hidden="true" />
574
574
  </button>
@@ -596,7 +596,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
596
596
  aria-label={filterBarVisible ? "Hide filters" : "Show filters"}
597
597
  onClick={() => setFilterBarVisible(v => !v)}
598
598
  className={cn(
599
- "inline-flex shrink-0 items-center gap-1 size-8 justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
599
+ "inline-flex shrink-0 cursor-pointer items-center gap-1 size-8 justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
600
600
  filterBarVisible
601
601
  ? "bg-accent text-accent-foreground hover:bg-accent/90"
602
602
  : "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover",
@@ -610,7 +610,7 @@ export function DataTableToolbar<TData extends Record<string, unknown>>({
610
610
  <DropdownMenuTrigger asChild>
611
611
  <button type="button" aria-label="Add filter"
612
612
  onClick={() => setFilterBarVisible(true)}
613
- className="inline-flex shrink-0 items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
613
+ className="inline-flex shrink-0 cursor-pointer items-center justify-center size-8 rounded-md text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
614
614
  >
615
615
  <i className="fa-light fa-filter text-[13px]" aria-hidden="true" />
616
616
  </button>
@@ -1325,19 +1325,33 @@ function DataTableInner<TData extends Record<string, unknown>>({
1325
1325
  const rowId = getRowId(row, rowIndex, getRowIdProp)
1326
1326
  const isSelected = selected.has(rowId)
1327
1327
  const isHovered = hoveredRow === rowId
1328
+ const rowClickable = Boolean(onRowClick) || selectable
1329
+ function handleRowClick(e: React.MouseEvent<HTMLTableRowElement>) {
1330
+ if (!rowClickable) return
1331
+ const el = e.target as HTMLElement | null
1332
+ if (!el) return
1333
+ if (el.closest("button, a, input, textarea, select, label, [role='checkbox']")) return
1334
+ if (onRowClick) {
1335
+ onRowClick(row)
1336
+ return
1337
+ }
1338
+ if (selectable) {
1339
+ toggleRow(rowId)
1340
+ }
1341
+ }
1328
1342
  return (
1329
1343
  <tr
1330
1344
  key={String(rowId)}
1331
1345
  data-state={isSelected ? "selected" : undefined}
1332
1346
  onMouseEnter={() => setHoveredRow(rowId)}
1333
1347
  onMouseLeave={() => setHoveredRow(null)}
1334
- onClick={onRowClick ? () => onRowClick(row) : undefined}
1348
+ onClick={rowClickable ? handleRowClick : undefined}
1335
1349
  data-new={Boolean((row as Record<string, unknown>).isNew) || undefined}
1336
1350
  className={cn(
1337
1351
  "group/row transition-colors",
1338
1352
  "hover:bg-dt-row-hover",
1339
1353
  isSelected && "bg-dt-row-selected text-dt-row-selected-fg",
1340
- onRowClick && "cursor-pointer",
1354
+ rowClickable && "cursor-pointer",
1341
1355
  Boolean((row as Record<string, unknown>).isNew) && "bg-dt-new-row-bg border-l-2 border-l-dt-new-row-border"
1342
1356
  )}
1343
1357
  >
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * FinderPanelView — Miller-style 3-column split for list-page hubs.
5
5
  *
6
- * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `bg-muted/15` columns,
6
+ * Visual shell matches Question bank panel (`ListPageTreeColumnHeader`, `LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS`,
7
7
  * shared resizable handles) — see `list-page-split-hub-tokens.ts`.
8
8
  */
9
9
 
@@ -142,7 +142,7 @@ export function FinderGroupStrip({
142
142
  <div
143
143
  role="toolbar"
144
144
  aria-label={ariaLabel}
145
- className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-muted/15 px-2 py-2"
145
+ className="flex min-h-10 flex-wrap items-center gap-1.5 border-b border-border bg-card px-2 py-2"
146
146
  >
147
147
  {groups.map(group => {
148
148
  const isSelected = group.id === selectedGroupId
@@ -47,6 +47,25 @@ export {
47
47
  type ListPageTreeColumnHeaderProps,
48
48
  } from "@/components/data-views/list-page-tree-column-header"
49
49
 
50
+ /** VS Code–style outline tree chrome — mirrors shadcn `SidebarMenuSub` (see module doc). */
51
+ export {
52
+ OutlineTreeCollapsibleContentRail,
53
+ OutlineTreeLeafButton,
54
+ OutlineTreeMenu,
55
+ OutlineTreeMenuItem,
56
+ OutlineTreeSub,
57
+ OutlineTreeSubItem,
58
+ OUTLINE_TREE_COLLAPSIBLE_CONTENT_RAIL_CLASS,
59
+ OUTLINE_TREE_CHEVRON_GUIDE_SPACER_CLASS,
60
+ OUTLINE_TREE_SUB_ROW_SHIFT_CLASS,
61
+ type OutlineTreeGuideLayout,
62
+ type OutlineTreeLeafButtonProps,
63
+ type OutlineTreeSurface,
64
+ } from "@/components/data-views/outline-tree-menu"
65
+
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"
68
+
50
69
  export {
51
70
  LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
52
71
  LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
@@ -10,7 +10,7 @@ export interface ListPageSplitDetailsPlaceholderProps {
10
10
  }
11
11
 
12
12
  /**
13
- * Empty right pane for split hubs — matches Question bank tree “Nothing selected”.
13
+ * Empty right pane for split hubs — flat `bg-card` to match Miller / tree columns.
14
14
  */
15
15
  export function ListPageSplitDetailsPlaceholder({
16
16
  title = "Nothing selected",
@@ -20,11 +20,11 @@ export function ListPageSplitDetailsPlaceholder({
20
20
  return (
21
21
  <div
22
22
  className={cn(
23
- "flex h-full min-h-0 flex-col items-center justify-center bg-gradient-to-b from-muted/25 via-card to-card px-6 py-10 text-center",
23
+ "flex h-full min-h-0 flex-col items-center justify-center bg-card px-6 py-10 text-center",
24
24
  className,
25
25
  )}
26
26
  >
27
- <div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/25">
27
+ <div className="mb-4 flex size-14 items-center justify-center rounded-2xl border border-dashed border-border/70 bg-card">
28
28
  <i
29
29
  className="fa-light fa-sidebar text-[1.65rem] leading-none text-muted-foreground/70"
30
30
  aria-hidden="true"
@@ -9,7 +9,7 @@ export const LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS =
9
9
 
10
10
  /** Primary column stack (scope list, folder list, record list, …). */
11
11
  export const LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS =
12
- "flex min-h-0 min-w-0 flex-col bg-muted/15"
12
+ "flex min-h-0 min-w-0 flex-col bg-card"
13
13
 
14
14
  /** Right-hand inspector / detail column shell. */
15
15
  export const LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS =
@@ -19,7 +19,7 @@ export function ListPageTreeColumnHeader({
19
19
  className,
20
20
  }: ListPageTreeColumnHeaderProps) {
21
21
  return (
22
- <div className={cn("shrink-0 border-b border-border/50 bg-muted/10 px-3 py-2", className)}>
22
+ <div className={cn("shrink-0 border-b border-border/50 bg-card px-3 py-2", className)}>
23
23
  <div className="flex h-9 items-center justify-between gap-2">
24
24
  <h3 className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{title}</h3>
25
25
  {trailing ? (