@exxatdesignux/ui 0.2.6 → 0.2.8

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 (134) hide show
  1. package/package.json +2 -1
  2. package/template/.agents/skills/shadcn/SKILL.md +242 -0
  3. package/template/.agents/skills/shadcn/agents/openai.yml +5 -0
  4. package/template/.agents/skills/shadcn/assets/shadcn-small.png +0 -0
  5. package/template/.agents/skills/shadcn/assets/shadcn.png +0 -0
  6. package/template/.agents/skills/shadcn/cli.md +257 -0
  7. package/template/.agents/skills/shadcn/customization.md +202 -0
  8. package/template/.agents/skills/shadcn/evals/evals.json +47 -0
  9. package/template/.agents/skills/shadcn/mcp.md +94 -0
  10. package/template/.agents/skills/shadcn/rules/base-vs-radix.md +306 -0
  11. package/template/.agents/skills/shadcn/rules/composition.md +195 -0
  12. package/template/.agents/skills/shadcn/rules/forms.md +192 -0
  13. package/template/.agents/skills/shadcn/rules/icons.md +101 -0
  14. package/template/.agents/skills/shadcn/rules/styling.md +162 -0
  15. package/template/.claude/skills/exxat-ds-skill/SKILL.md +712 -0
  16. package/template/.cursor/rules/exxat-accessibility.mdc +33 -0
  17. package/template/.cursor/rules/exxat-command-menu.mdc +23 -0
  18. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +53 -0
  19. package/template/.cursor/rules/exxat-data-tables.mdc +31 -0
  20. package/template/.cursor/rules/exxat-ds-agents.mdc +26 -0
  21. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +100 -0
  22. package/template/.cursor/rules/exxat-list-page-connected-views.mdc +16 -0
  23. package/template/.cursor/rules/exxat-no-toast.mdc +26 -0
  24. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +22 -0
  25. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +40 -0
  26. package/template/AGENTS.md +52 -11
  27. package/template/app/(app)/dashboard/page.tsx +1 -1
  28. package/template/app/(app)/data-list/[id]/page.tsx +24 -8
  29. package/template/app/(app)/data-list/new/page.tsx +7 -4
  30. package/template/app/(app)/data-list/page.tsx +1 -1
  31. package/template/app/(app)/examples/page.tsx +41 -0
  32. package/template/app/(app)/question-bank/page.tsx +3 -3
  33. package/template/components/app-sidebar.tsx +52 -35
  34. package/template/components/compliance-table.tsx +79 -0
  35. package/template/components/data-list-client.tsx +36 -25
  36. package/template/components/data-list-table.tsx +797 -10
  37. package/template/components/data-views/finder-panel-view.tsx +405 -0
  38. package/template/components/data-views/folder-grid-view.tsx +86 -0
  39. package/template/components/data-views/index.ts +59 -0
  40. package/template/components/data-views/list-page-split-details-placeholder.tsx +39 -0
  41. package/template/components/data-views/list-page-split-hub-chrome.tsx +60 -0
  42. package/template/components/data-views/list-page-split-hub-tokens.ts +16 -0
  43. package/template/components/data-views/list-page-tree-column-header.tsx +31 -0
  44. package/template/components/data-views/list-page-tree-panel-shell.tsx +91 -0
  45. package/template/components/data-views/list-page-view-frame.tsx +53 -0
  46. package/template/components/data-views/os-folder-glyph.tsx +121 -0
  47. package/template/components/folder-details-shell.tsx +230 -0
  48. package/template/components/hub-tree-panel-view.tsx +672 -0
  49. package/template/components/list-hub-status-badge.tsx +17 -3
  50. package/template/components/placements-page-header.tsx +14 -8
  51. package/template/components/placements-table-columns.tsx +8 -8
  52. package/template/components/question-bank-client.tsx +157 -40
  53. package/template/components/question-bank-new-folder-sheet.tsx +248 -0
  54. package/template/components/question-bank-os-folder-view.tsx +648 -0
  55. package/template/components/question-bank-page-header.tsx +3 -3
  56. package/template/components/question-bank-panel-activator.tsx +9 -0
  57. package/template/components/question-bank-secondary-nav.tsx +226 -0
  58. package/template/components/question-bank-table.tsx +707 -22
  59. package/template/components/secondary-panel.tsx +41 -107
  60. package/template/components/sites-table.tsx +66 -0
  61. package/template/components/team-client.tsx +7 -0
  62. package/template/components/team-table.tsx +156 -1
  63. package/template/components/templates/list-page.tsx +2 -2
  64. package/template/components/ui/avatar.tsx +1 -1
  65. package/template/components/ui/badge.tsx +1 -1
  66. package/template/components/ui/banner.tsx +1 -1
  67. package/template/components/ui/breadcrumb.tsx +1 -1
  68. package/template/components/ui/button.tsx +1 -1
  69. package/template/components/ui/calendar.tsx +1 -1
  70. package/template/components/ui/card.tsx +1 -1
  71. package/template/components/ui/chart.tsx +1 -1
  72. package/template/components/ui/checkbox.tsx +1 -1
  73. package/template/components/ui/coach-mark.tsx +1 -1
  74. package/template/components/ui/collapsible.tsx +1 -1
  75. package/template/components/ui/command.tsx +1 -1
  76. package/template/components/ui/date-picker-field.tsx +1 -1
  77. package/template/components/ui/dialog.tsx +1 -1
  78. package/template/components/ui/drag-handle-grip.tsx +1 -1
  79. package/template/components/ui/drawer.tsx +1 -1
  80. package/template/components/ui/dropdown-menu.tsx +1 -1
  81. package/template/components/ui/field.tsx +1 -1
  82. package/template/components/ui/form.tsx +1 -1
  83. package/template/components/ui/input-group.tsx +1 -1
  84. package/template/components/ui/input-mask.tsx +1 -1
  85. package/template/components/ui/input.tsx +1 -1
  86. package/template/components/ui/kbd.tsx +1 -1
  87. package/template/components/ui/label.tsx +1 -1
  88. package/template/components/ui/payment-card-fields.tsx +1 -1
  89. package/template/components/ui/popover.tsx +1 -1
  90. package/template/components/ui/radio-group.tsx +1 -1
  91. package/template/components/ui/resizable.tsx +68 -0
  92. package/template/components/ui/select.tsx +1 -1
  93. package/template/components/ui/selection-tile-grid.tsx +1 -1
  94. package/template/components/ui/separator.tsx +1 -1
  95. package/template/components/ui/sheet.tsx +1 -1
  96. package/template/components/ui/sidebar.tsx +1 -1
  97. package/template/components/ui/skeleton.tsx +1 -1
  98. package/template/components/ui/sonner.tsx +1 -1
  99. package/template/components/ui/status-badge.tsx +1 -1
  100. package/template/components/ui/table.tsx +1 -1
  101. package/template/components/ui/tabs.tsx +1 -1
  102. package/template/components/ui/textarea.tsx +1 -1
  103. package/template/components/ui/tip.tsx +1 -1
  104. package/template/components/ui/toggle-group.tsx +1 -1
  105. package/template/components/ui/toggle-switch.tsx +1 -1
  106. package/template/components/ui/toggle.tsx +1 -1
  107. package/template/components/ui/tooltip.tsx +1 -1
  108. package/template/components/ui/view-segmented-control.tsx +1 -1
  109. package/template/docs/data-views-pattern.md +7 -0
  110. package/template/fontawesome-subset.manifest.json +2 -2
  111. package/template/hooks/use-location-hash.ts +15 -0
  112. package/template/hooks/use-sidebar-reflow-zoom.ts +40 -0
  113. package/template/lib/ask-leo-route-context.ts +25 -57
  114. package/template/lib/coach-mark-registry.ts +13 -13
  115. package/template/lib/command-menu-config.ts +28 -23
  116. package/template/lib/command-menu-search-data.ts +10 -9
  117. package/template/lib/data-list-view-surface.ts +12 -1
  118. package/template/lib/data-list-view.ts +6 -3
  119. package/template/lib/mock/dashboard.ts +11 -11
  120. package/template/lib/mock/navigation.tsx +22 -63
  121. package/template/lib/mock/placements-kpi.ts +19 -19
  122. package/template/lib/mock/question-bank-folders.ts +167 -0
  123. package/template/lib/mock/question-bank-inspector.ts +109 -0
  124. package/template/lib/mock/question-bank-kpi.ts +1 -1
  125. package/template/lib/mock/question-bank.ts +80 -0
  126. package/template/lib/question-bank-nav.ts +91 -0
  127. package/template/next.config.mjs +8 -0
  128. package/template/package.json +1 -0
  129. package/template/public/folders/icons8-folder-windows-11.svg +1 -0
  130. package/template/scripts/fontawesome-subset-audit.mjs +2 -3
  131. package/template/app/(app)/compliance/page.tsx +0 -10
  132. package/template/app/(app)/rotations/page.tsx +0 -15
  133. package/template/app/(app)/sites/all/page.tsx +0 -13
  134. package/template/app/(app)/team/page.tsx +0 -10
@@ -17,14 +17,21 @@ export const LIST_HUB_STATUS_BADGE_TABLE_SHELL =
17
17
  export const LIST_HUB_STATUS_BADGE_BOARD_SHELL =
18
18
  "inline-flex h-6 items-center gap-1 border-0 px-2 py-1 text-xs font-medium leading-none shadow-none"
19
19
 
20
+ /**
21
+ * Inspector / split-pane detail headers — uniform 24px chip height next to status badges.
22
+ * Reuse on sibling `Badge`s so rows align with `ListHubStatusBadge` `surface="detail"`.
23
+ */
24
+ export const LIST_HUB_INSPECTOR_CHIP_SHELL =
25
+ "inline-flex h-6 min-h-6 shrink-0 items-center gap-1.5 px-2 py-0 text-xs font-medium leading-none"
26
+
20
27
  export interface ListHubStatusBadgeProps {
21
28
  label: string
22
29
  /** Tails from `*_STATUS_BADGE_CLASS` in `@/lib/list-status-badges` */
23
30
  tintClassName: string
24
31
  /** Font Awesome icon class suffix, e.g. `fa-circle-check` (paired with `fa-light` here). */
25
32
  icon: string
26
- /** `table` — DataTable cells and list rows; `board` — `ListPageBoardCardBadgeRow`. */
27
- surface?: "table" | "board"
33
+ /** `table` — grid cells; `board` kanban cards; `detail` — hub inspector / tree detail column. */
34
+ surface?: "table" | "board" | "detail"
28
35
  className?: string
29
36
  }
30
37
 
@@ -35,11 +42,18 @@ export function ListHubStatusBadge({
35
42
  surface = "table",
36
43
  className,
37
44
  }: ListHubStatusBadgeProps) {
45
+ const shell =
46
+ surface === "board"
47
+ ? LIST_HUB_STATUS_BADGE_BOARD_SHELL
48
+ : surface === "detail"
49
+ ? LIST_HUB_INSPECTOR_CHIP_SHELL
50
+ : LIST_HUB_STATUS_BADGE_TABLE_SHELL
51
+
38
52
  return (
39
53
  <Badge
40
54
  variant="outline"
41
55
  className={cn(
42
- surface === "board" ? LIST_HUB_STATUS_BADGE_BOARD_SHELL : LIST_HUB_STATUS_BADGE_TABLE_SHELL,
56
+ shell,
43
57
  tintClassName,
44
58
  className,
45
59
  )}
@@ -17,23 +17,29 @@ import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
17
17
  import { isEditableTarget } from "@/lib/editable-target"
18
18
 
19
19
  export interface PlacementsPageHeaderProps {
20
+ /** Main heading in the page header */
21
+ title?: string
22
+ /** Primary button label */
23
+ primaryCtaLabel?: string
20
24
  /** Shown under the page title */
21
25
  subtitle?: string
22
26
  onNewPlacement: () => void
23
27
  onExport: () => void
24
28
  showMetrics: boolean
25
29
  onToggleMetrics: () => void
26
- /** When false, “Placements” + subtitle are hidden visually (Display options). */
30
+ /** When false, title + subtitle are hidden visually (Display options). */
27
31
  showTitleBlock?: boolean
28
32
  className?: string
29
33
  }
30
34
 
31
35
  /**
32
- * Placements list shell header — title, primary CTA, overflow menu (export, metrics).
33
- * Reusable for any placements route that needs the same chrome.
36
+ * List hub shell header — title, primary CTA, overflow menu (export, metrics).
37
+ * Reusable for any route that needs the same chrome.
34
38
  */
35
39
  export function PlacementsPageHeader({
36
- subtitle = "24 records · Last updated now",
40
+ title = "Sample records",
41
+ primaryCtaLabel = "New row",
42
+ subtitle = "24 demo rows · Last updated now",
37
43
  onNewPlacement,
38
44
  onExport,
39
45
  showMetrics,
@@ -76,17 +82,17 @@ export function PlacementsPageHeader({
76
82
  <Shortcut keys="⌘⇧E" onInvoke={onExport} />
77
83
  <Shortcut keys="⌘⌥H" onInvoke={onToggleMetrics} />
78
84
  <PageHeader
79
- title="Placements"
85
+ title={title}
80
86
  subtitle={subtitle}
81
87
  className={className}
82
88
  showTitleBlock={showTitleBlock}
83
89
  actions={
84
- <div className="flex items-center gap-2" role="group" aria-label="Placement actions">
90
+ <div className="flex items-center gap-2" role="group" aria-label="Primary list actions">
85
91
  <Tip
86
92
  side="bottom"
87
93
  label={
88
94
  <>
89
- <span>New placement</span>
95
+ <span>{primaryCtaLabel}</span>
90
96
  <KbdGroup>
91
97
  <Kbd>{mod}</Kbd>
92
98
  <Kbd>{alt}</Kbd>
@@ -97,7 +103,7 @@ export function PlacementsPageHeader({
97
103
  >
98
104
  <Button size="lg" onClick={onNewPlacement}>
99
105
  <i className="fa-light fa-plus" aria-hidden="true" />
100
- New placement
106
+ {primaryCtaLabel}
101
107
  </Button>
102
108
  </Tip>
103
109
  <DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
@@ -621,19 +621,19 @@ export function getPlacementColumnsForLifecycle(tab: PlacementLifecycleTabId): C
621
621
  export function emptyCopyForPlacementLifecycleTab(tab: PlacementLifecycleTabId): string {
622
622
  switch (tab) {
623
623
  case "upcoming":
624
- return "No upcoming placements match your filters."
624
+ return "No rows in this segment match your filters."
625
625
  case "ongoing":
626
- return "No ongoing placements match your filters."
626
+ return "No rows in this segment match your filters."
627
627
  case "completed":
628
- return "No completed placements match your filters."
628
+ return "No rows in this segment match your filters."
629
629
  default:
630
- return "No placements match your filters."
630
+ return "No rows match your filters."
631
631
  }
632
632
  }
633
633
 
634
634
  export const placementLifecycleDrawerLabels: Record<PlacementLifecycleTabId, string> = {
635
- all: "Lifecycle: All placements",
636
- upcoming: "Lifecycle: Upcoming",
637
- ongoing: "Lifecycle: Ongoing",
638
- completed: "Lifecycle: Completed",
635
+ all: "Segment: All rows",
636
+ upcoming: "Segment: Due soon",
637
+ ongoing: "Segment: In progress",
638
+ completed: "Segment: Done",
639
639
  }
@@ -2,20 +2,31 @@
2
2
 
3
3
  /**
4
4
  * Question bank hub — ListPageTemplate + KeyMetrics + QuestionBankTable (Team / Compliance pattern).
5
+ * URL hash syncs the active view tab; `?scope=` + `folderId=` sync with the secondary nav (`lib/question-bank-nav.ts`).
5
6
  */
6
7
 
7
8
  import * as React from "react"
9
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
8
10
  import {
9
11
  ListPageTemplate,
10
12
  type ViewTab,
11
13
  dataListViewIcon,
12
14
  type DataListViewType,
13
15
  } from "@/components/data-views"
16
+ import { QuestionBankPanelActivator } from "@/components/question-bank-panel-activator"
14
17
  import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
15
18
  import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
19
+ import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
20
+ import { useSecondaryPanel } from "@/components/secondary-panel"
16
21
  import { KeyMetrics } from "@/components/key-metrics"
17
22
  import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
23
+ import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
18
24
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
25
+ import {
26
+ filterQuestionBankItemsByNav,
27
+ parseQuestionBankNav,
28
+ questionBankHubHeaderModel,
29
+ } from "@/lib/question-bank-nav"
19
30
 
20
31
  const DEFAULT_TABS: ViewTab[] = [
21
32
  {
@@ -25,54 +36,160 @@ const DEFAULT_TABS: ViewTab[] = [
25
36
  icon: "fa-table",
26
37
  filterId: "all",
27
38
  },
39
+ {
40
+ id: "panel-view",
41
+ label: "Panel",
42
+ viewType: "panel",
43
+ icon: "fa-columns",
44
+ filterId: "all",
45
+ },
46
+ {
47
+ id: "tree-panel",
48
+ label: "Tree",
49
+ viewType: "tree-panel",
50
+ icon: "fa-sitemap",
51
+ filterId: "all",
52
+ },
28
53
  ]
29
54
 
55
+ function questionBankQueryPrefixFromSearchString(qs: string) {
56
+ return qs ? `?${qs}` : ""
57
+ }
58
+
30
59
  export function QuestionBankClient() {
60
+ const pathname = usePathname()
61
+ const router = useRouter()
62
+ const searchParams = useSearchParams()
63
+ const { openPanel } = useSecondaryPanel()
64
+ const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
65
+ const [activeTabId, setActiveTabId] = React.useState(DEFAULT_TABS[0].id)
66
+
67
+ const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
68
+
69
+ /** String key — `useSearchParams()` identity can stay stable when only the query changes. */
70
+ const searchParamsKey = searchParams.toString()
71
+ const navState = React.useMemo(
72
+ () => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
73
+ [searchParamsKey],
74
+ )
75
+
76
+ /** “All questions” hub — keep secondary nav open when scope clears (breadcrumb, All questions link). */
77
+ React.useEffect(() => {
78
+ if (pathname !== "/question-bank") return
79
+ if (navState.scope !== "all") return
80
+ openPanel("question-bank")
81
+ }, [pathname, navState.scope, openPanel])
82
+
83
+ React.useEffect(() => {
84
+ if (pathname !== "/question-bank") return
85
+ const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
86
+ const apply = () => {
87
+ const raw = typeof window !== "undefined" ? window.location.hash.slice(1) : ""
88
+ let nextId = "questions"
89
+ if (raw === "panel-view" || raw === "tree-panel") {
90
+ nextId = raw
91
+ } else if (raw && tabIds.has(raw)) {
92
+ nextId = raw
93
+ }
94
+ setActiveTabId(nextId)
95
+ if (nextId === "questions" && raw && raw !== "questions") {
96
+ router.replace(`/question-bank${prefix}`, { scroll: false })
97
+ }
98
+ }
99
+ apply()
100
+ window.addEventListener("hashchange", apply)
101
+ return () => window.removeEventListener("hashchange", apply)
102
+ }, [pathname, router, tabIds, searchParamsKey])
103
+
104
+ const onActiveTabChange = React.useCallback(
105
+ (id: string) => {
106
+ setActiveTabId(id)
107
+ if (pathname !== "/question-bank") return
108
+ const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
109
+ if (id === "questions") {
110
+ router.replace(`/question-bank${prefix}`, { scroll: false })
111
+ } else {
112
+ router.replace(`/question-bank${prefix}#${id}`, { scroll: false })
113
+ }
114
+ },
115
+ [pathname, router, searchParamsKey],
116
+ )
117
+
31
118
  const [exportOpen, setExportOpen] = React.useState(false)
32
119
  const [showMetrics, setShowMetrics] = React.useState(true)
33
120
  const tableRef = React.useRef<QuestionBankTableHandle>(null)
34
- const count = QUESTION_BANK_ITEMS.length
121
+ const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
122
+ const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
35
123
 
36
- const metrics = React.useMemo(() => questionBankKpiMetrics(QUESTION_BANK_ITEMS), [])
37
- const insight = React.useMemo(() => questionBankKpiInsight(QUESTION_BANK_ITEMS), [])
124
+ const filteredItems = React.useMemo(
125
+ () => filterQuestionBankItemsByNav(items, folders, navState),
126
+ [items, folders, navState],
127
+ )
128
+
129
+ const count = filteredItems.length
130
+
131
+ const metrics = React.useMemo(() => questionBankKpiMetrics(filteredItems), [filteredItems])
132
+ const insight = React.useMemo(() => questionBankKpiInsight(filteredItems), [filteredItems])
133
+
134
+ const hubHeader = React.useMemo(
135
+ () => questionBankHubHeaderModel(folders, navState),
136
+ [folders, navState],
137
+ )
38
138
 
39
139
  return (
40
- <ListPageTemplate
41
- defaultTabs={DEFAULT_TABS}
42
- getTabCount={() => count}
43
- tablePropertiesRef={tableRef}
44
- header={(
45
- <QuestionBankPageHeader
46
- variant="collaboration"
47
- questionCount={count}
48
- onNewQuestion={() => {}}
49
- onExport={() => setExportOpen(true)}
50
- showMetrics={showMetrics}
51
- onToggleMetrics={() => setShowMetrics(v => !v)}
52
- />
53
- )}
54
- metrics={(
55
- <KeyMetrics
56
- variant="flat"
57
- metrics={metrics}
58
- insight={insight}
59
- showHeader={false}
60
- metricsSingleRow
61
- />
62
- )}
63
- showMetrics={showMetrics}
64
- exportOpen={exportOpen}
65
- onExportOpenChange={setExportOpen}
66
- exportTotalRows={count}
67
- renderContent={(tab, updateTab) => (
68
- <QuestionBankTable
69
- key={tab.id}
70
- ref={tableRef}
71
- items={QUESTION_BANK_ITEMS}
72
- view={tab.viewType}
73
- onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
74
- />
75
- )}
76
- />
140
+ <PrimaryPageTemplate
141
+ beforeSiteHeader={<QuestionBankPanelActivator />}
142
+ siteHeader={{
143
+ title: hubHeader.title,
144
+ breadcrumbs: hubHeader.breadcrumbs,
145
+ }}
146
+ >
147
+ <ListPageTemplate
148
+ defaultTabs={DEFAULT_TABS}
149
+ tabs={tabs}
150
+ onTabsChange={setTabs}
151
+ activeTabId={activeTabId}
152
+ onActiveTabChange={onActiveTabChange}
153
+ getTabCount={() => count}
154
+ tablePropertiesRef={tableRef}
155
+ header={(
156
+ <QuestionBankPageHeader
157
+ variant="collaboration"
158
+ title={hubHeader.title}
159
+ questionCount={count}
160
+ onNewQuestion={() => {}}
161
+ onExport={() => setExportOpen(true)}
162
+ showMetrics={showMetrics}
163
+ onToggleMetrics={() => setShowMetrics(v => !v)}
164
+ />
165
+ )}
166
+ metrics={(
167
+ <KeyMetrics
168
+ variant="flat"
169
+ metrics={metrics}
170
+ insight={insight}
171
+ showHeader={false}
172
+ metricsSingleRow
173
+ />
174
+ )}
175
+ showMetrics={showMetrics}
176
+ exportOpen={exportOpen}
177
+ onExportOpenChange={setExportOpen}
178
+ exportTotalRows={count}
179
+ renderContent={(tab, updateTab) => (
180
+ <QuestionBankTable
181
+ key={tab.id}
182
+ ref={tableRef}
183
+ items={items}
184
+ navState={navState}
185
+ folders={folders}
186
+ onFoldersChange={setFolders}
187
+ onItemsChange={setItems}
188
+ view={tab.viewType}
189
+ onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
190
+ />
191
+ )}
192
+ />
193
+ </PrimaryPageTemplate>
77
194
  )
78
195
  }
@@ -0,0 +1,248 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Shared “new folder” floating sheet (same shell as Export) — used by OS folder grid and column panel.
5
+ */
6
+
7
+ import * as React from "react"
8
+ import { Button } from "@/components/ui/button"
9
+ import { Shortcut } from "@/components/ui/dropdown-menu"
10
+ import { Input } from "@/components/ui/input"
11
+ import { Label } from "@/components/ui/label"
12
+ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
13
+ import { Tip } from "@/components/ui/tip"
14
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
15
+ import { cn } from "@/lib/utils"
16
+ import {
17
+ QUESTION_BANK_FOLDER_COLOR_STYLES,
18
+ QUESTION_BANK_FOLDER_ICON_OPTIONS,
19
+ type QuestionBankFolderColorKey,
20
+ } from "@/lib/mock/question-bank-folders"
21
+ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
22
+
23
+ const COLOR_OPTIONS: QuestionBankFolderColorKey[] = [
24
+ "brand",
25
+ "success",
26
+ "warning",
27
+ "destructive",
28
+ "muted",
29
+ "chart1",
30
+ "chart2",
31
+ "chart3",
32
+ ]
33
+
34
+ function FolderTilePreview({
35
+ name,
36
+ colorKey,
37
+ icon,
38
+ className,
39
+ }: {
40
+ name: string
41
+ colorKey: QuestionBankFolderColorKey
42
+ icon: string
43
+ className?: string
44
+ }) {
45
+ const display = name.trim() || "Untitled"
46
+ return (
47
+ <div
48
+ className={cn(
49
+ "flex flex-col items-center gap-4 rounded-2xl border border-border/80 bg-muted/20 p-6 shadow-sm",
50
+ className,
51
+ )}
52
+ >
53
+ <OsFolderGlyph
54
+ colorKey={colorKey}
55
+ icon={icon}
56
+ size="lg"
57
+ decorative={false}
58
+ label={`Folder preview: ${display}`}
59
+ />
60
+ <p className="line-clamp-2 min-h-[2.5rem] w-full text-center text-sm font-medium text-foreground">
61
+ {display}
62
+ </p>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ export interface QuestionBankNewFolderSheetProps {
68
+ open: boolean
69
+ onOpenChange: (open: boolean) => void
70
+ /** Parent folder id for the new folder (`null` = top level). */
71
+ parentFolderId: string | null
72
+ /** Replaces default helper copy under the title. */
73
+ descriptionText?: string
74
+ /** When provided, the sheet is in "customize" mode with these initial values. */
75
+ customizingFolder?: {
76
+ name: string
77
+ icon: string
78
+ colorKey: QuestionBankFolderColorKey
79
+ parentId: string | null
80
+ } | null
81
+ onCreated: (folder: {
82
+ name: string
83
+ icon: string
84
+ colorKey: QuestionBankFolderColorKey
85
+ parentId: string | null
86
+ }) => void
87
+ }
88
+
89
+ export function QuestionBankNewFolderSheet({
90
+ open,
91
+ onOpenChange,
92
+ parentFolderId,
93
+ customizingFolder,
94
+ descriptionText = "Name, color, and icon update the preview. The folder is created in the location shown in the breadcrumb above the grid.",
95
+ onCreated,
96
+ }: QuestionBankNewFolderSheetProps) {
97
+ const [draft, setDraft] = React.useState<{
98
+ name: string
99
+ colorKey: QuestionBankFolderColorKey
100
+ icon: string
101
+ }>({ name: "Untitled", colorKey: "brand", icon: "fa-folder" })
102
+
103
+ React.useEffect(() => {
104
+ if (open) {
105
+ if (customizingFolder) {
106
+ setDraft({ name: customizingFolder.name, colorKey: customizingFolder.colorKey, icon: customizingFolder.icon })
107
+ } else {
108
+ setDraft({ name: "Untitled", colorKey: "brand", icon: "fa-folder" })
109
+ }
110
+ }
111
+ }, [open, customizingFolder])
112
+
113
+ const createDisabled = !draft.name.trim()
114
+
115
+ function commit() {
116
+ const v = draft.name.trim()
117
+ if (!v) return
118
+ onCreated({
119
+ name: v,
120
+ icon: draft.icon,
121
+ colorKey: draft.colorKey,
122
+ parentId: parentFolderId,
123
+ })
124
+ onOpenChange(false)
125
+ }
126
+
127
+ return (
128
+ <Sheet open={open} onOpenChange={onOpenChange}>
129
+ <SheetContent
130
+ data-slot="new-folder-drawer"
131
+ side="right"
132
+ showCloseButton={false}
133
+ showOverlay={false}
134
+ className="z-[60] flex w-full max-w-md flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl sm:max-w-md"
135
+ style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
136
+ >
137
+ {open ? (
138
+ <>
139
+ <Shortcut keys="Enter" onInvoke={() => !createDisabled && commit()} />
140
+ <Shortcut keys="Esc" onInvoke={() => onOpenChange(false)} />
141
+
142
+ <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-6">
143
+ <SheetTitle className="text-base font-semibold leading-tight">
144
+ {customizingFolder ? "Customize Folder" : "New folder"}
145
+ </SheetTitle>
146
+ <Tip label="Close" side="bottom">
147
+ <Button
148
+ type="button"
149
+ variant="ghost"
150
+ size="icon-sm"
151
+ aria-label="Close"
152
+ onClick={() => onOpenChange(false)}
153
+ >
154
+ <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
155
+ </Button>
156
+ </Tip>
157
+ </div>
158
+
159
+ <div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 pb-4 pt-2">
160
+ <div className="flex flex-col items-center gap-4">
161
+ <FolderTilePreview
162
+ name={draft.name}
163
+ colorKey={draft.colorKey}
164
+ icon={draft.icon}
165
+ className="w-full max-w-[280px]"
166
+ />
167
+ <div className="w-full max-w-[280px] space-y-2">
168
+ <Label htmlFor="new-folder-name-shared">Folder name</Label>
169
+ <Input
170
+ id="new-folder-name-shared"
171
+ value={draft.name}
172
+ onChange={e => setDraft(d => ({ ...d, name: e.target.value }))}
173
+ autoComplete="off"
174
+ aria-describedby="new-folder-panel-desc new-folder-name-hint-shared"
175
+ aria-invalid={createDisabled}
176
+ />
177
+ <p id="new-folder-name-hint-shared" className="text-sm text-muted-foreground">
178
+ Shown under the folder icon in the grid or column.
179
+ </p>
180
+ </div>
181
+ </div>
182
+
183
+ <div>
184
+ <p className="mb-2 text-xs font-medium text-muted-foreground">Color</p>
185
+ <div className="flex flex-wrap gap-2">
186
+ {COLOR_OPTIONS.map(c => (
187
+ <button
188
+ key={c}
189
+ type="button"
190
+ aria-label={`Color ${c}`}
191
+ aria-pressed={draft.colorKey === c}
192
+ className={cn(
193
+ "size-10 rounded-xl border-2 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
194
+ QUESTION_BANK_FOLDER_COLOR_STYLES[c].tile,
195
+ draft.colorKey === c
196
+ ? "ring-2 ring-ring"
197
+ : "border-transparent opacity-85 hover:opacity-100",
198
+ )}
199
+ onClick={() => setDraft(d => ({ ...d, colorKey: c }))}
200
+ />
201
+ ))}
202
+ </div>
203
+ </div>
204
+
205
+ <div>
206
+ <p className="mb-2 text-xs font-medium text-muted-foreground">Icon</p>
207
+ <div className="grid max-h-48 grid-cols-5 gap-2 overflow-y-auto rounded-xl border border-border p-3">
208
+ {QUESTION_BANK_FOLDER_ICON_OPTIONS.map(ic => (
209
+ <button
210
+ key={ic}
211
+ type="button"
212
+ aria-label={`Icon ${ic.replace(/^fa-/, "").replace(/-/g, " ")}`}
213
+ aria-pressed={draft.icon === ic}
214
+ className={cn(
215
+ "flex size-10 items-center justify-center rounded-lg border text-sm transition-colors",
216
+ draft.icon === ic
217
+ ? "border-brand bg-brand/10 text-brand"
218
+ : "border-transparent hover:bg-muted",
219
+ )}
220
+ onClick={() => setDraft(d => ({ ...d, icon: ic }))}
221
+ >
222
+ <i className={cn("fa-light", ic)} aria-hidden="true" />
223
+ </button>
224
+ ))}
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <div className="mt-auto flex flex-row flex-wrap justify-end gap-2 border-t border-border px-4 py-4">
230
+ <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
231
+ Cancel
232
+ <KbdGroup className="ms-1.5">
233
+ <Kbd variant="bare">Esc</Kbd>
234
+ </KbdGroup>
235
+ </Button>
236
+ <Button type="button" disabled={createDisabled} onClick={commit}>
237
+ {customizingFolder ? "Update folder" : "Create folder"}
238
+ <KbdGroup className="ms-1.5">
239
+ <Kbd variant="bare">⏎</Kbd>
240
+ </KbdGroup>
241
+ </Button>
242
+ </div>
243
+ </>
244
+ ) : null}
245
+ </SheetContent>
246
+ </Sheet>
247
+ )
248
+ }