@exxatdesignux/ui 0.2.17 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +22 -7
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  9. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +10 -3
  10. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  11. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  12. package/consumer-extras/patterns/data-views-pattern.md +42 -3
  13. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  14. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  15. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +54 -0
  16. package/package.json +2 -1
  17. package/src/components/ui/button-group.tsx +81 -0
  18. package/src/components/ui/button.tsx +4 -4
  19. package/src/components/ui/sidebar.tsx +2 -2
  20. package/src/globals.css +7 -1807
  21. package/src/theme.css +10 -1126
  22. package/src/tokens/README.md +15 -0
  23. package/src/tokens/base.css +337 -0
  24. package/src/tokens/high-contrast.css +1195 -0
  25. package/src/tokens/layers.css +224 -0
  26. package/src/tokens/tailwind-bridge.css +118 -0
  27. package/src/tokens/themes.css +201 -0
  28. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  29. package/template/AGENTS.md +66 -21
  30. package/template/app/(app)/dashboard/loading.tsx +3 -15
  31. package/template/app/(app)/dashboard/page.tsx +2 -14
  32. package/template/app/(app)/data-list/layout.tsx +43 -0
  33. package/template/app/(app)/data-list/page.tsx +2 -2
  34. package/template/app/(app)/error.tsx +22 -6
  35. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  36. package/template/app/(app)/examples/page.tsx +1 -0
  37. package/template/app/(app)/layout.tsx +13 -6
  38. package/template/app/(app)/loading.tsx +1 -18
  39. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  40. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  41. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  42. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  43. package/template/app/(app)/question-bank/page.tsx +2 -1
  44. package/template/app/(app)/settings/page.tsx +4 -5
  45. package/template/app/global-error.tsx +63 -0
  46. package/template/app/globals.css +7 -1934
  47. package/template/app/layout.tsx +2 -0
  48. package/template/components/app-route-loading.tsx +14 -0
  49. package/template/components/app-sidebar.tsx +71 -55
  50. package/template/components/data-table/index.tsx +31 -67
  51. package/template/components/data-table/use-table-state.ts +33 -6
  52. package/template/components/data-views/index.ts +37 -9
  53. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  54. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  55. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  56. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  57. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  58. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  59. package/template/components/exxat-product-logo.tsx +2 -6
  60. package/template/components/key-metrics.tsx +54 -22
  61. package/template/components/list-hub-board-view.tsx +68 -0
  62. package/template/components/list-hub-client.tsx +186 -0
  63. package/template/components/list-hub-list-view.tsx +36 -0
  64. package/template/components/list-hub-panel-activator.tsx +8 -0
  65. package/template/components/list-hub-secondary-nav.tsx +121 -0
  66. package/template/components/list-hub-table.tsx +336 -0
  67. package/template/components/new-question-composer.tsx +6 -24
  68. package/template/components/product-switcher.tsx +5 -5
  69. package/template/components/product-wordmark.tsx +4 -7
  70. package/template/components/question-bank-client.tsx +4 -1
  71. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  72. package/template/components/question-bank-hub-client.tsx +2 -5
  73. package/template/components/question-bank-table.tsx +155 -509
  74. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  75. package/template/components/secondary-panel.tsx +4 -44
  76. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  77. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  78. package/template/components/secondary-panels/registry.tsx +15 -0
  79. package/template/components/settings-appearance-card.tsx +3 -2
  80. package/template/components/settings-client.tsx +59 -15
  81. package/template/components/settings-form-row.tsx +9 -4
  82. package/template/components/sidebar-shell.tsx +2 -1
  83. package/template/components/table-properties/drawer-button.tsx +51 -20
  84. package/template/components/table-properties/drawer.tsx +81 -17
  85. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  86. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  87. package/template/components/templates/list-page.tsx +40 -13
  88. package/template/components/templates/nested-secondary-panel-shell.tsx +3 -2
  89. package/template/components/templates/page-loading-shell.tsx +262 -0
  90. package/template/components/ui/button-group.tsx +1 -0
  91. package/template/contexts/product-context.tsx +21 -2
  92. package/template/docs/consumer-app-pattern.md +39 -0
  93. package/template/docs/data-views-pattern.md +42 -3
  94. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  95. package/template/docs/focused-workflow-page-pattern.md +84 -0
  96. package/template/docs/kpi-flat-band-pattern.md +57 -0
  97. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  98. package/template/docs/shell-surface-elevation-pattern.md +54 -0
  99. package/template/lib/chunk-load-error.ts +13 -0
  100. package/template/lib/command-menu-search-data.ts +11 -27
  101. package/template/lib/conditional-rule-match.ts +87 -22
  102. package/template/lib/data-list-display-options.ts +16 -2
  103. package/template/lib/data-list-view-registry.ts +104 -0
  104. package/template/lib/data-list-view-surface.ts +15 -1
  105. package/template/lib/data-list-view.ts +16 -1
  106. package/template/lib/data-view-dashboard-storage.ts +38 -35
  107. package/template/lib/hub-connected-view-renderers.ts +58 -0
  108. package/template/lib/list-hub-nav.ts +121 -0
  109. package/template/lib/list-hub-supported-views.ts +10 -0
  110. package/template/lib/list-page-table-properties.ts +3 -7
  111. package/template/lib/list-status-badges.ts +4 -97
  112. package/template/lib/mock/list-hub-directory.ts +27 -0
  113. package/template/lib/mock/list-hub-kpi.ts +27 -0
  114. package/template/lib/mock/navigation.tsx +1 -0
  115. package/template/lib/page-loading-variant.ts +40 -0
  116. package/template/lib/question-bank-supported-views.ts +13 -0
  117. package/template/lib/sidebar-state-cookie.ts +9 -0
  118. package/template/lib/table-state-lifecycle.ts +60 -13
  119. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  120. package/template/app/(app)/data-list/new/page.tsx +0 -34
  121. package/template/components/compliance-board-view.tsx +0 -142
  122. package/template/components/compliance-client.tsx +0 -92
  123. package/template/components/compliance-list-view.tsx +0 -54
  124. package/template/components/compliance-page-header.tsx +0 -89
  125. package/template/components/compliance-table.tsx +0 -632
  126. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  127. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  128. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  129. package/template/components/new-placement-back-btn.tsx +0 -28
  130. package/template/components/new-placement-form.tsx +0 -1068
  131. package/template/components/placement-board-card.tsx +0 -262
  132. package/template/components/placement-detail.tsx +0 -438
  133. package/template/components/placements-board-view.tsx +0 -404
  134. package/template/components/placements-client.tsx +0 -252
  135. package/template/components/placements-list-view.tsx +0 -171
  136. package/template/components/placements-page-header.tsx +0 -166
  137. package/template/components/placements-table-cells.test.tsx +0 -22
  138. package/template/components/placements-table-cells.tsx +0 -173
  139. package/template/components/placements-table-columns.tsx +0 -640
  140. package/template/components/placements-table.tsx +0 -1675
  141. package/template/components/rotations-empty-state.tsx +0 -50
  142. package/template/components/rotations-panel-activator.tsx +0 -8
  143. package/template/components/sites-all-client.tsx +0 -154
  144. package/template/components/sites-board-view.tsx +0 -67
  145. package/template/components/sites-list-view.tsx +0 -42
  146. package/template/components/sites-table.tsx +0 -402
  147. package/template/components/team-board-view.tsx +0 -122
  148. package/template/components/team-client.tsx +0 -100
  149. package/template/components/team-list-view.tsx +0 -59
  150. package/template/components/team-page-header.tsx +0 -92
  151. package/template/components/team-table.tsx +0 -714
  152. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  153. package/template/lib/mock/compliance-kpi.ts +0 -61
  154. package/template/lib/mock/compliance.ts +0 -146
  155. package/template/lib/mock/placements-kpi.ts +0 -134
  156. package/template/lib/mock/placements.ts +0 -183
  157. package/template/lib/mock/sites-directory.ts +0 -16
  158. package/template/lib/mock/sites-kpi.ts +0 -25
  159. package/template/lib/mock/team-kpi.ts +0 -60
  160. package/template/lib/mock/team.ts +0 -118
  161. package/template/lib/placement-board-card-layout.ts +0 -79
  162. package/template/lib/placement-lifecycle.ts +0 -5
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * Question bank — DataTable + TablePropertiesDrawer + list/board/dashboard (KPI + charts on dashboard).
4
+ * Question bank — DataTable + TablePropertiesDrawer + connected views via `ListPageConnectedViewBody`.
5
5
  */
6
6
 
7
7
  import * as React from "react"
@@ -10,9 +10,9 @@ import { mailtoHref } from "@/lib/mailto"
10
10
  import { DataTable, DataTableToolbar } from "@/components/data-table"
11
11
  import type { DataListViewType } from "@/lib/data-list-view"
12
12
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
13
+ import { QUESTION_BANK_SUPPORTED_VIEWS } from "@/lib/question-bank-supported-views"
13
14
  import type { ColumnDef } from "@/components/data-table/types"
14
15
  import { useTableState } from "@/components/data-table/use-table-state"
15
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
16
16
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
17
17
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
18
18
  import { Button } from "@/components/ui/button"
@@ -24,23 +24,11 @@ import {
24
24
  } from "@/components/ui/dropdown-menu"
25
25
  import { Tip } from "@/components/ui/tip"
26
26
  import { Skeleton } from "@/components/ui/skeleton"
27
- import {
28
- ResizableHandle,
29
- ResizablePanel,
30
- ResizablePanelGroup,
31
- } from "@/components/ui/resizable"
32
- import {
33
- Tooltip,
34
- TooltipContent,
35
- TooltipTrigger,
36
- } from "@/components/ui/tooltip"
27
+ import { ListPageConnectedViewBody } from "@/components/data-views/list-page-connected-view-body"
28
+ import { ListPageCalendarView } from "@/components/data-views/list-page-calendar-view"
37
29
  import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
38
- import {
39
- LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
40
- LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
41
- LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
42
- } from "@/components/data-views/list-page-split-hub-tokens"
43
- import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
30
+ import { defineHubViewRenderers } from "@/lib/hub-connected-view-renderers"
31
+ import { QuestionBankFolderColumnsPanel } from "@/components/question-bank-folder-columns-panel"
44
32
  import { QuestionBankBoardView, QUESTION_BANK_BOARD_GROUP_OPTIONS } from "@/components/question-bank-board-view"
45
33
  import { QuestionBankListView } from "@/components/question-bank-list-view"
46
34
  import {
@@ -61,7 +49,7 @@ import {
61
49
  type QuestionBankItem,
62
50
  type QuestionBankType,
63
51
  } from "@/lib/mock/question-bank"
64
- import { type QuestionBankFolder, QUESTION_BANK_FOLDER_COLOR_STYLES, QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
52
+ import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
65
53
  import {
66
54
  toggleQuestionBankItemFavorite,
67
55
  applyQuestionBankHubDisplayFilters,
@@ -329,312 +317,6 @@ function buildQuestionBankColumns(
329
317
  return cols
330
318
  }
331
319
 
332
- interface HubFolderColumnsPanelProps {
333
- folders: QuestionBankFolder[]
334
- rows: QuestionBankItem[]
335
- panelRenderDetail: (row: QuestionBankItem) => React.ReactNode
336
- onAddFolder: (parentId: string | null) => void
337
- onAddQuestion: (parentId: string | null) => void
338
- onCustomizeFolder?: (folder: QuestionBankFolder) => void
339
- }
340
-
341
- type HierarchyItem = QuestionBankFolder | QuestionBankItem
342
-
343
- function isFolder(item: HierarchyItem): item is QuestionBankFolder {
344
- return 'parentId' in item
345
- }
346
-
347
- function isQuestion(item: HierarchyItem): item is QuestionBankItem {
348
- return 'stem' in item
349
- }
350
-
351
- /** **Panel view** — multi-column folder explorer + optional detail column (Finder-style). */
352
- function HubFolderColumnsPanel({
353
- folders,
354
- rows,
355
- panelRenderDetail,
356
- onAddFolder,
357
- onAddQuestion,
358
- onCustomizeFolder,
359
- }: HubFolderColumnsPanelProps) {
360
- // Track the selected path through the hierarchy
361
- // Initialize with first folder selected by default
362
- const [selectedPath, setSelectedPath] = React.useState<HierarchyItem[]>(() => {
363
- const rootFolders = folders
364
- .filter(f => f.parentId === null)
365
- .sort((a, b) => a.name.localeCompare(b.name))
366
- if (rootFolders.length > 0) {
367
- return [rootFolders[0]]
368
- }
369
- return []
370
- })
371
-
372
- // Track if this is the first render for auto-selection
373
- const isFirstRenderRef = React.useRef(true)
374
-
375
- // Get root items (top-level folders)
376
- const rootFolders = React.useMemo(() => {
377
- return folders
378
- .filter(f => f.parentId === null)
379
- .sort((a, b) => a.name.localeCompare(b.name))
380
- }, [folders])
381
-
382
- // Handle selection at any depth
383
- const handleSelect = (item: HierarchyItem, depth: number) => {
384
- setSelectedPath(prev => [...prev.slice(0, depth), item])
385
- }
386
-
387
- // Auto-select first item at each level (only on first render). Intentional
388
- // empty deps: we want this to run exactly once on mount; depending on the
389
- // referenced values (folders / rows / selectedPath) would re-run on every
390
- // edit and keep re-seeding the selection, undoing the user's choice.
391
- React.useEffect(() => {
392
- if (isFirstRenderRef.current && selectedPath.length > 0) {
393
- const lastItem = selectedPath[selectedPath.length - 1]
394
- if (isFolder(lastItem)) {
395
- const folder = lastItem as QuestionBankFolder
396
- const subfolders = folders.filter(f => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name))
397
- const questionsInFolder = rows.filter(r => r.folderId === folder.id)
398
- const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
399
-
400
- if (items.length > 0 && !selectedPath[selectedPath.length + 1]) {
401
- setSelectedPath(prev => [...prev, items[0]])
402
- isFirstRenderRef.current = false
403
- }
404
- }
405
- }
406
- // eslint-disable-next-line react-hooks/exhaustive-deps
407
- }, [])
408
-
409
- // Build columns dynamically based on selected path
410
- const columns: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> = React.useMemo(() => {
411
- const cols: Array<{ items: HierarchyItem[]; depth: number; parentId?: string | null }> = [
412
- { items: rootFolders, depth: 0, parentId: null },
413
- ]
414
-
415
- // For each selected folder in the path, add a column with its children
416
- for (let i = 0; i < selectedPath.length; i++) {
417
- const item = selectedPath[i]
418
- if (isFolder(item)) {
419
- // Get subfolders
420
- const subfolders = folders
421
- .filter(f => f.parentId === item.id)
422
- .sort((a, b) => a.name.localeCompare(b.name))
423
-
424
- // Get questions in this folder
425
- const questionsInFolder = rows.filter(r => r.folderId === item.id)
426
-
427
- // Combine folders and questions
428
- const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
429
-
430
- if (items.length > 0) {
431
- cols.push({ items, depth: i + 1, parentId: item.id })
432
- }
433
- }
434
- }
435
-
436
- return cols
437
- }, [selectedPath, rootFolders, folders, rows])
438
-
439
- const selectedLeaf = selectedPath.length > 0 ? selectedPath.at(-1)! : null
440
- const selectedQuestion =
441
- selectedLeaf && isQuestion(selectedLeaf) ? (selectedLeaf as QuestionBankItem) : null
442
- const selectedFolderLeaf =
443
- selectedLeaf && isFolder(selectedLeaf) ? (selectedLeaf as QuestionBankFolder) : null
444
-
445
- return (
446
- <ResizablePanelGroup
447
- direction="horizontal"
448
- className="flex h-full min-h-0 w-full flex-1 overflow-hidden"
449
- >
450
- {/* Render all columns with handles between them */}
451
- {columns.map(({ items, depth, parentId }, columnIdx) => (
452
- <React.Fragment key={`col-${depth}`}>
453
- {columnIdx > 0 && (
454
- <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
455
- )}
456
- <ResizablePanel
457
- id={`col-${depth}`}
458
- defaultSize={columnIdx === 0 ? 35 : columnIdx === 1 ? 35 : 30}
459
- minSize={15}
460
- className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}
461
- >
462
- <ListPageTreeColumnHeader
463
- title={
464
- depth === 0
465
- ? "Categories"
466
- : selectedPath[depth - 1] && isFolder(selectedPath[depth - 1])
467
- ? (selectedPath[depth - 1] as QuestionBankFolder).name
468
- : "Items"
469
- }
470
- trailing={
471
- <>
472
- <span className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums">
473
- {items.length}
474
- </span>
475
- {depth < columns.length - 1 && items.length > 0 ? (
476
- <div className="flex shrink-0 items-center gap-0.5">
477
- <Tooltip>
478
- <TooltipTrigger asChild>
479
- <Button
480
- size="icon-sm"
481
- variant="ghost"
482
- onClick={() => onAddFolder(parentId ?? null)}
483
- aria-label="Add folder"
484
- >
485
- <i className="fa-light fa-folder-plus text-xs" aria-hidden="true" />
486
- </Button>
487
- </TooltipTrigger>
488
- <TooltipContent side="top" sideOffset={4}>
489
- Add folder
490
- </TooltipContent>
491
- </Tooltip>
492
- <Tooltip>
493
- <TooltipTrigger asChild>
494
- <Button
495
- size="icon-sm"
496
- variant="ghost"
497
- onClick={() => onAddQuestion(parentId ?? null)}
498
- aria-label="Add question"
499
- >
500
- <i className="fa-light fa-plus text-xs" aria-hidden="true" />
501
- </Button>
502
- </TooltipTrigger>
503
- <TooltipContent side="top" sideOffset={4}>
504
- Add question
505
- </TooltipContent>
506
- </Tooltip>
507
- </div>
508
- ) : null}
509
- </>
510
- }
511
- />
512
-
513
- {/* Scrollable Items List */}
514
- <div className="min-h-0 flex-1 overflow-y-auto py-1">
515
- {items.map(item => {
516
- const isSelected = selectedPath[depth]?.id === item.id
517
- const isFolder_ = isFolder(item)
518
- const folder = isFolder_ ? item : null
519
- const question = isQuestion(item) ? item : null
520
-
521
- // Get count for folders
522
- const subfolderCount = isFolder_
523
- ? folders.filter(f => f.parentId === item.id).length
524
- : 0
525
- const questionCount = isFolder_
526
- ? rows.filter(r => r.folderId === item.id).length
527
- : 0
528
- const itemCount = subfolderCount + questionCount
529
-
530
- return (
531
- <div
532
- key={item.id}
533
- className="group flex items-center hover:bg-muted/50"
534
- >
535
- <button
536
- onClick={() => handleSelect(item, depth)}
537
- className={cn(
538
- "flex flex-1 items-center gap-3 px-3 py-2 text-left text-sm transition-colors duration-75",
539
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
540
- isSelected
541
- ? "bg-accent text-accent-foreground"
542
- : "text-foreground",
543
- // Apply folder background color if it's a folder and not selected, but NOT in the first column
544
- isFolder_ && !isSelected && folder?.colorKey && depth > 0
545
- ? QUESTION_BANK_FOLDER_COLOR_STYLES[folder.colorKey]?.tile
546
- : "",
547
- )}
548
- aria-selected={isSelected}
549
- role="option"
550
- >
551
- {/* Icon - show for folders and questions */}
552
- {isFolder_ ? (
553
- <i className={cn(
554
- "fa-folder text-sm shrink-0",
555
- isSelected ? "fa-solid" : "fa-light",
556
- // Apply folder color from customization (for both selected and unselected)
557
- folder?.colorKey && QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey]
558
- )} aria-hidden="true" />
559
- ) : (
560
- <i className={cn("fa-file text-sm shrink-0", isSelected ? "fa-solid" : "fa-light")} aria-hidden="true" />
561
- )}
562
-
563
- {/* Name */}
564
- <span className={cn(
565
- "min-w-0 flex-1 truncate leading-tight",
566
- isSelected && "font-medium"
567
- )}>
568
- {isFolder_ ? folder?.name : question?.stem}
569
- </span>
570
-
571
- {/* Count or metadata */}
572
- <span className={cn(
573
- "shrink-0 tabular-nums text-xs ml-auto",
574
- isSelected ? "text-accent-foreground/70" : "text-muted-foreground",
575
- )}>
576
- {isFolder_ ? itemCount : (question?.type === 'multiple_choice' ? 'MCQ' : question?.difficulty?.charAt(0).toUpperCase())}
577
- </span>
578
- </button>
579
-
580
- {/* Folder actions menu - only for folders */}
581
- {isFolder_ && folder && (
582
- <DropdownMenu>
583
- <DropdownMenuTrigger asChild>
584
- <Button
585
- type="button"
586
- size="icon-xs"
587
- variant="ghost"
588
- aria-label={`Actions for folder ${folder.name}`}
589
- className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
590
- >
591
- <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
592
- </Button>
593
- </DropdownMenuTrigger>
594
- <DropdownMenuContent align="end">
595
- <DropdownMenuItem onSelect={() => onCustomizeFolder?.(folder)}>
596
- <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
597
- Customize
598
- </DropdownMenuItem>
599
- </DropdownMenuContent>
600
- </DropdownMenu>
601
- )}
602
- </div>
603
- )
604
- })}
605
- </div>
606
- </ResizablePanel>
607
- </React.Fragment>
608
- ))}
609
-
610
- {/* Details panel — question (summary) or folder (aggregates) */}
611
- {(selectedQuestion || selectedFolderLeaf) && (
612
- <>
613
- <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
614
- <ResizablePanel id="col-detail" defaultSize={30} minSize={20} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
615
- {selectedQuestion ? (
616
- <>
617
- <ListPageTreeColumnHeader title="Details" className="px-4" />
618
- <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
619
- {panelRenderDetail(selectedQuestion)}
620
- </div>
621
- </>
622
- ) : selectedFolderLeaf ? (
623
- <div className="min-h-0 flex-1 overflow-hidden">
624
- <FolderDetailsShell
625
- folder={selectedFolderLeaf}
626
- folders={folders}
627
- questions={rows}
628
- />
629
- </div>
630
- ) : null}
631
- </ResizablePanel>
632
- </>
633
- )}
634
- </ResizablePanelGroup>
635
- )
636
- }
637
-
638
320
  export type QuestionBankTableHandle = OpenTablePropertiesHandle
639
321
 
640
322
  export const QuestionBankTable = React.forwardRef<
@@ -651,12 +333,26 @@ export const QuestionBankTable = React.forwardRef<
651
333
  landingFilters?: QuestionBankLandingFilterState | null
652
334
  view?: DataListViewType
653
335
  onViewChange?: (v: DataListViewType) => void
336
+ /** Aligns Properties view tiles with `ListPageTemplate` `supportedViewTypes`. */
337
+ supportedViewTypes?: readonly DataListViewType[]
654
338
  folders: QuestionBankFolder[]
655
339
  onFoldersChange: React.Dispatch<React.SetStateAction<QuestionBankFolder[]>>
656
340
  onItemsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
657
341
  }
658
342
  >(function QuestionBankTable(
659
- { items, navState, urlListSearch, searchLanding, landingFilters, view = "table", onViewChange, folders, onFoldersChange, onItemsChange },
343
+ {
344
+ items,
345
+ navState,
346
+ urlListSearch,
347
+ searchLanding,
348
+ landingFilters,
349
+ view = "table",
350
+ onViewChange,
351
+ supportedViewTypes = QUESTION_BANK_SUPPORTED_VIEWS,
352
+ folders,
353
+ onFoldersChange,
354
+ onItemsChange,
355
+ },
660
356
  ref,
661
357
  ) {
662
358
  const tableSourceItems = React.useMemo(() => {
@@ -718,28 +414,6 @@ export const QuestionBankTable = React.forwardRef<
718
414
  searchLanding ? undefined : urlListSearch,
719
415
  )
720
416
 
721
- // Persist this hub's table lifecycle (sort / search / filters / column
722
- // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
723
- // NOTE: tabId is `"main"` here — the question-bank folder scope is
724
- // already URL-driven (`?scope=`, `?folderId=`), so we only persist
725
- // table chrome, not navigation.
726
- const lifecycleColumnKeys = React.useMemo(
727
- () => new Set(columns.map(c => c.key)),
728
- [columns],
729
- )
730
- useTableStateLifecycle({
731
- namespace: "question-bank",
732
- tabId: "main",
733
- tableState,
734
- columnKeys: lifecycleColumnKeys,
735
- extras: { conditionalRules },
736
- onLoadExtras: e => {
737
- if (e && Array.isArray(e.conditionalRules)) {
738
- setConditionalRules(e.conditionalRules as ConditionalRule[])
739
- }
740
- },
741
- })
742
-
743
417
  const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
744
418
  setNewFolderParentId(parentId)
745
419
  setCustomizingFolder(null)
@@ -861,6 +535,7 @@ export const QuestionBankTable = React.forwardRef<
861
535
  onUpdateConditionalRule: updateConditionalRule,
862
536
  currentView: view,
863
537
  onViewChange,
538
+ supportedViewTypes,
864
539
  lifecycleTabLabel: "Question bank",
865
540
  boardGroupByColumnOptions: [...QUESTION_BANK_BOARD_GROUP_OPTIONS],
866
541
  renderFilterOptionValue,
@@ -911,172 +586,143 @@ export const QuestionBankTable = React.forwardRef<
911
586
  />
912
587
  )
913
588
 
914
- if (view === "table") {
915
- return (
916
- <div className="pb-6">
917
- <DataTable<QuestionBankItem> {...tableProps} />
918
- </div>
919
- )
920
- }
921
-
922
- if (view === "list") {
923
- return (
924
- <div className="flex min-h-0 flex-1 flex-col">
925
- {sharedToolbar}
926
- <QuestionBankListView
927
- rows={tableState.rows as QuestionBankItem[]}
928
- onToggleFavorite={toggleFavorite}
929
- onRowActivate={row => tableState.toggleRow(row.id)}
930
- />
931
- </div>
932
- )
933
- }
934
-
935
- if (view === "board") {
936
- return (
937
- <div className="flex min-h-0 flex-1 flex-col">
938
- {sharedToolbar}
939
- <QuestionBankBoardView
940
- rows={tableState.rows as QuestionBankItem[]}
941
- groupByColumnKey={questionBankBoardGroupKey}
942
- onToggleFavorite={toggleFavorite}
943
- onRowActivate={row => tableState.toggleRow(row.id)}
944
- />
945
- </div>
946
- )
947
- }
589
+ const toolbarShell = (body: React.ReactNode) => (
590
+ <div className="flex min-h-0 flex-1 flex-col">
591
+ {sharedToolbar}
592
+ {body}
593
+ </div>
594
+ )
948
595
 
949
- if (view === "folder") {
950
- return (
951
- <div className="flex min-h-0 flex-1 flex-col">
952
- {sharedToolbar}
953
- <QuestionBankOsFolderView
954
- folders={folders}
955
- onFoldersChange={onFoldersChange}
956
- questions={tableState.rows as QuestionBankItem[]}
957
- onQuestionsChange={onItemsChange}
958
- />
959
- </div>
960
- )
961
- }
596
+ const handleFolderSheetCreated = React.useCallback(
597
+ (newFolder: {
598
+ name: string
599
+ icon: string
600
+ colorKey: QuestionBankFolder["colorKey"]
601
+ parentId: string | null
602
+ }) => {
603
+ if (customizingFolder) {
604
+ onFoldersChange(prev =>
605
+ prev.map(f =>
606
+ f.id === customizingFolder.id
607
+ ? { ...f, name: newFolder.name, icon: newFolder.icon, colorKey: newFolder.colorKey }
608
+ : f,
609
+ ),
610
+ )
611
+ setCustomizingFolder(null)
612
+ } else {
613
+ onFoldersChange(prev => [
614
+ ...prev,
615
+ {
616
+ id: `fld-${Date.now()}`,
617
+ name: newFolder.name,
618
+ icon: newFolder.icon,
619
+ colorKey: newFolder.colorKey,
620
+ parentId: newFolder.parentId,
621
+ },
622
+ ])
623
+ }
624
+ setNewFolderOpen(false)
625
+ },
626
+ [customizingFolder, onFoldersChange],
627
+ )
962
628
 
963
- if (view === "panel") {
964
- return (
965
- <>
966
- <div className="flex min-h-0 flex-1 flex-col">
967
- {sharedToolbar}
968
- <ListPageSplitHubChrome aria-label="Question bank folder columns">
969
- <HubFolderColumnsPanel
970
- folders={folders}
971
- rows={tableState.rows as QuestionBankItem[]}
972
- panelRenderDetail={panelRenderDetail}
973
- onAddFolder={openNewFolderForColumn}
974
- onAddQuestion={addQuestionInColumn}
975
- onCustomizeFolder={openCustomizeFolderSheet}
976
- />
977
- </ListPageSplitHubChrome>
978
- </div>
979
- <QuestionBankNewFolderSheet
980
- open={newFolderOpen}
981
- onOpenChange={setNewFolderOpen}
982
- parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
983
- customizingFolder={customizingFolder}
984
- onCreated={(newFolder) => {
985
- if (customizingFolder) {
986
- // Update existing folder
987
- onFoldersChange(prev =>
988
- prev.map(f =>
989
- f.id === customizingFolder.id
990
- ? {
991
- ...f,
992
- name: newFolder.name,
993
- icon: newFolder.icon,
994
- colorKey: newFolder.colorKey,
995
- }
996
- : f,
997
- ),
998
- )
999
- setCustomizingFolder(null)
1000
- } else {
1001
- // Create new folder
1002
- onFoldersChange(prev => [
1003
- ...prev,
1004
- {
1005
- id: `fld-${Date.now()}`,
1006
- name: newFolder.name,
1007
- icon: newFolder.icon,
1008
- colorKey: newFolder.colorKey,
1009
- parentId: newFolder.parentId,
1010
- },
1011
- ])
1012
- }
1013
- setNewFolderOpen(false)
1014
- }}
1015
- />
1016
- </>
1017
- )
1018
- }
629
+ const folderSheet = (
630
+ <QuestionBankNewFolderSheet
631
+ open={newFolderOpen}
632
+ onOpenChange={setNewFolderOpen}
633
+ parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
634
+ customizingFolder={customizingFolder}
635
+ onCreated={handleFolderSheetCreated}
636
+ />
637
+ )
1019
638
 
1020
- if (view === "tree-panel") {
1021
- return (
1022
- <>
1023
- <div className="flex min-h-0 flex-1 flex-col">
1024
- {sharedToolbar}
1025
- <div className="flex min-h-0 flex-1 flex-col">
1026
- <HubTreePanelView
1027
- items={tableState.rows as QuestionBankItem[]}
1028
- folders={folders}
1029
- onItemsChange={onItemsChange}
1030
- onFoldersChange={onFoldersChange}
1031
- />
1032
- </div>
1033
- </div>
1034
- <QuestionBankNewFolderSheet
1035
- open={newFolderOpen}
1036
- onOpenChange={setNewFolderOpen}
1037
- parentFolderId={customizingFolder?.parentId ?? newFolderParentId}
1038
- customizingFolder={customizingFolder}
1039
- onCreated={(newFolder) => {
1040
- if (customizingFolder) {
1041
- // Update existing folder
1042
- onFoldersChange(prev =>
1043
- prev.map(f =>
1044
- f.id === customizingFolder.id
1045
- ? {
1046
- ...f,
1047
- name: newFolder.name,
1048
- icon: newFolder.icon,
1049
- colorKey: newFolder.colorKey,
1050
- }
1051
- : f,
1052
- ),
1053
- )
1054
- setCustomizingFolder(null)
1055
- } else {
1056
- // Create new folder
1057
- onFoldersChange(prev => [
1058
- ...prev,
1059
- {
1060
- id: `fld-${Date.now()}`,
1061
- name: newFolder.name,
1062
- icon: newFolder.icon,
1063
- colorKey: newFolder.colorKey,
1064
- parentId: newFolder.parentId,
1065
- },
1066
- ])
1067
- }
1068
- setNewFolderOpen(false)
1069
- }}
1070
- />
1071
- </>
1072
- )
1073
- }
639
+ const rows = tableState.rows as QuestionBankItem[]
1074
640
 
1075
641
  return (
1076
- <div className="flex min-h-0 flex-1 flex-col">
1077
- {sharedToolbar}
1078
- <QuestionBankDashboardChartsSection rows={tableState.rows as QuestionBankItem[]} />
1079
- </div>
642
+ <ListPageConnectedViewBody
643
+ view={view}
644
+ hubLabel="Question bank"
645
+ renderers={defineHubViewRenderers(QUESTION_BANK_SUPPORTED_VIEWS, {
646
+ "data-table": (
647
+ <div className="pb-6">
648
+ <DataTable<QuestionBankItem> {...tableProps} />
649
+ </div>
650
+ ),
651
+ "list-with-toolbar": toolbarShell(
652
+ <QuestionBankListView
653
+ rows={rows}
654
+ onToggleFavorite={toggleFavorite}
655
+ onRowActivate={row => tableState.toggleRow(row.id)}
656
+ />,
657
+ ),
658
+ "board-with-toolbar": toolbarShell(
659
+ <QuestionBankBoardView
660
+ rows={rows}
661
+ groupByColumnKey={questionBankBoardGroupKey}
662
+ onToggleFavorite={toggleFavorite}
663
+ onRowActivate={row => tableState.toggleRow(row.id)}
664
+ />,
665
+ ),
666
+ "folder-with-toolbar": toolbarShell(
667
+ <QuestionBankOsFolderView
668
+ folders={folders}
669
+ onFoldersChange={onFoldersChange}
670
+ questions={rows}
671
+ onQuestionsChange={onItemsChange}
672
+ />,
673
+ ),
674
+ "panel-with-toolbar": (
675
+ <>
676
+ {toolbarShell(
677
+ <ListPageSplitHubChrome aria-label="Question bank folder columns">
678
+ <QuestionBankFolderColumnsPanel
679
+ folders={folders}
680
+ rows={rows}
681
+ panelRenderDetail={panelRenderDetail}
682
+ onAddFolder={openNewFolderForColumn}
683
+ onAddQuestion={addQuestionInColumn}
684
+ onCustomizeFolder={openCustomizeFolderSheet}
685
+ />
686
+ </ListPageSplitHubChrome>,
687
+ )}
688
+ {folderSheet}
689
+ </>
690
+ ),
691
+ "tree-panel-with-toolbar": (
692
+ <>
693
+ {toolbarShell(
694
+ <div className="flex min-h-0 flex-1 flex-col">
695
+ <HubTreePanelView
696
+ items={rows}
697
+ folders={folders}
698
+ onItemsChange={onItemsChange}
699
+ onFoldersChange={onFoldersChange}
700
+ />
701
+ </div>,
702
+ )}
703
+ {folderSheet}
704
+ </>
705
+ ),
706
+ "calendar-with-toolbar": toolbarShell(
707
+ <ListPageCalendarView
708
+ rows={rows}
709
+ getRowId={row => row.id}
710
+ getEventDate={row => row.updatedAt}
711
+ getEventLabel={row => row.stem}
712
+ getEventMeta={row => row.topic}
713
+ emptyMonthLabel="No questions on this day."
714
+ ariaLabel="Question bank calendar"
715
+ showSummaryPanel={displayOptions.showCalendarSummaryPanel}
716
+ calendarMainView={displayOptions.calendarMainView}
717
+ onCalendarMainViewChange={v => patchDisplay({ calendarMainView: v })}
718
+ onEventActivate={row => tableState.toggleRow(row.id)}
719
+ />,
720
+ ),
721
+ "dashboard-with-toolbar": toolbarShell(
722
+ <QuestionBankDashboardChartsSection rows={rows} />,
723
+ ),
724
+ })}
725
+ />
1080
726
  )
1081
727
  })
1082
728