@exxatdesignux/ui 0.0.6 → 0.0.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 (264) hide show
  1. package/bin/init.mjs +29 -0
  2. package/package.json +7 -2
  3. package/template/.nvmrc +1 -0
  4. package/template/.prettierignore +7 -0
  5. package/template/.prettierrc +11 -0
  6. package/template/AGENTS.md +485 -0
  7. package/template/Logo/Exxat_Prism.svg +39 -0
  8. package/template/Logo/Exxat_one.svg +36 -0
  9. package/template/README.md +58 -0
  10. package/template/app/(app)/compliance/page.tsx +10 -0
  11. package/template/app/(app)/dashboard/loading.tsx +18 -0
  12. package/template/app/(app)/dashboard/page.tsx +36 -0
  13. package/template/app/(app)/data-list/[id]/page.tsx +28 -0
  14. package/template/app/(app)/data-list/new/page.tsx +31 -0
  15. package/template/app/(app)/data-list/page.tsx +10 -0
  16. package/template/app/(app)/error.tsx +43 -0
  17. package/template/app/(app)/help/page.tsx +34 -0
  18. package/template/app/(app)/layout.tsx +54 -0
  19. package/template/app/(app)/loading.tsx +18 -0
  20. package/template/app/(app)/question-bank/page.tsx +10 -0
  21. package/template/app/(app)/rotations/page.tsx +15 -0
  22. package/template/app/(app)/settings/page.tsx +17 -0
  23. package/template/app/(app)/sites/all/page.tsx +13 -0
  24. package/template/app/(app)/team/page.tsx +10 -0
  25. package/template/app/favicon.ico +0 -0
  26. package/template/app/globals.css +1811 -0
  27. package/template/app/layout.tsx +95 -0
  28. package/template/app/page.tsx +9 -0
  29. package/template/components/.gitkeep +0 -0
  30. package/template/components/app-sidebar-dynamic.tsx +15 -0
  31. package/template/components/app-sidebar.tsx +901 -0
  32. package/template/components/ask-leo-composer.tsx +216 -0
  33. package/template/components/ask-leo-sidebar.tsx +509 -0
  34. package/template/components/chart-area-interactive.tsx +293 -0
  35. package/template/components/charts-overview.tsx +2321 -0
  36. package/template/components/command-menu-01.tsx +133 -0
  37. package/template/components/command-menu-02.tsx +386 -0
  38. package/template/components/command-menu.tsx +182 -0
  39. package/template/components/compliance-board-view.tsx +134 -0
  40. package/template/components/compliance-client.tsx +92 -0
  41. package/template/components/compliance-list-view.tsx +59 -0
  42. package/template/components/compliance-page-header.tsx +89 -0
  43. package/template/components/compliance-table.tsx +525 -0
  44. package/template/components/dashboard-onboarding-gallery.tsx +13 -0
  45. package/template/components/dashboard-onboarding.tsx +21 -0
  46. package/template/components/dashboard-promo-banner.tsx +67 -0
  47. package/template/components/dashboard-quota-progress-card.tsx +369 -0
  48. package/template/components/dashboard-report-charts.tsx +69 -0
  49. package/template/components/dashboard-section-heading.tsx +68 -0
  50. package/template/components/dashboard-tabs.tsx +598 -0
  51. package/template/components/data-list-client.tsx +239 -0
  52. package/template/components/data-list-table-cells.test.tsx +22 -0
  53. package/template/components/data-list-table-cells.tsx +173 -0
  54. package/template/components/data-list-table.tsx +879 -0
  55. package/template/components/data-table/filter-date-calendar.tsx +38 -0
  56. package/template/components/data-table/filter-text-value-input.tsx +77 -0
  57. package/template/components/data-table/index.tsx +1612 -0
  58. package/template/components/data-table/pagination.tsx +256 -0
  59. package/template/components/data-table/types.ts +91 -0
  60. package/template/components/data-table/use-table-state.ts +566 -0
  61. package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
  62. package/template/components/data-view-dashboard-charts-team.tsx +968 -0
  63. package/template/components/data-view-dashboard-charts.tsx +1668 -0
  64. package/template/components/data-views/board-card-primitives.tsx +93 -0
  65. package/template/components/data-views/index.ts +41 -0
  66. package/template/components/data-views/list-page-board-card.tsx +192 -0
  67. package/template/components/data-views/list-page-board-template.tsx +122 -0
  68. package/template/components/data-views/placement-board-card.tsx +262 -0
  69. package/template/components/export-drawer.tsx +375 -0
  70. package/template/components/exxat-product-logo.tsx +453 -0
  71. package/template/components/form-layout-01.tsx +131 -0
  72. package/template/components/getting-started.tsx +625 -0
  73. package/template/components/key-metrics.tsx +920 -0
  74. package/template/components/leo-insight-indicator.tsx +364 -0
  75. package/template/components/leo-typing-dots.tsx +121 -0
  76. package/template/components/list-hub-status-badge.tsx +51 -0
  77. package/template/components/list-page-dashboard-charts.tsx +18 -0
  78. package/template/components/nav-documents.tsx +89 -0
  79. package/template/components/nav-main.tsx +58 -0
  80. package/template/components/nav-secondary.tsx +64 -0
  81. package/template/components/nav-user.tsx +190 -0
  82. package/template/components/new-placement-back-btn.tsx +28 -0
  83. package/template/components/new-placement-form.tsx +1066 -0
  84. package/template/components/onboarding/index.ts +4 -0
  85. package/template/components/onboarding/onboarding-01.tsx +7 -0
  86. package/template/components/onboarding/onboarding-02.tsx +7 -0
  87. package/template/components/onboarding/onboarding-03.tsx +7 -0
  88. package/template/components/onboarding/onboarding-04.tsx +7 -0
  89. package/template/components/page-header.tsx +57 -0
  90. package/template/components/placement-detail.tsx +438 -0
  91. package/template/components/placements-board-view.tsx +404 -0
  92. package/template/components/placements-list-view.tsx +285 -0
  93. package/template/components/placements-page-header.tsx +160 -0
  94. package/template/components/placements-table-columns.tsx +639 -0
  95. package/template/components/product-switcher.tsx +116 -0
  96. package/template/components/question-bank-board-view.tsx +205 -0
  97. package/template/components/question-bank-client.tsx +77 -0
  98. package/template/components/question-bank-list-view.tsx +59 -0
  99. package/template/components/question-bank-page-header.tsx +89 -0
  100. package/template/components/question-bank-table.tsx +586 -0
  101. package/template/components/rotations-empty-state.tsx +47 -0
  102. package/template/components/rotations-panel-activator.tsx +8 -0
  103. package/template/components/secondary-nav.tsx +394 -0
  104. package/template/components/secondary-panel.tsx +239 -0
  105. package/template/components/section-cards.tsx +106 -0
  106. package/template/components/settings-appearance-card.tsx +424 -0
  107. package/template/components/settings-client.tsx +537 -0
  108. package/template/components/settings-form-row.tsx +42 -0
  109. package/template/components/sidebar-auto-collapse.tsx +23 -0
  110. package/template/components/sidebar-auto-open.tsx +18 -0
  111. package/template/components/sidebar-shell.tsx +37 -0
  112. package/template/components/site-header.tsx +93 -0
  113. package/template/components/sites-all-client.tsx +154 -0
  114. package/template/components/sites-board-view.tsx +67 -0
  115. package/template/components/sites-list-view.tsx +47 -0
  116. package/template/components/sites-table.tsx +312 -0
  117. package/template/components/system-banner-slot.tsx +66 -0
  118. package/template/components/table-properties/column-row.tsx +90 -0
  119. package/template/components/table-properties/draggable-list.ts +49 -0
  120. package/template/components/table-properties/drawer-button.tsx +231 -0
  121. package/template/components/table-properties/drawer.tsx +1102 -0
  122. package/template/components/table-properties/filter-card.tsx +251 -0
  123. package/template/components/table-properties/index.ts +22 -0
  124. package/template/components/table-properties/sort-card.tsx +59 -0
  125. package/template/components/table-properties/types.ts +124 -0
  126. package/template/components/task-list-panel.tsx +98 -0
  127. package/template/components/task-priority-badge.tsx +28 -0
  128. package/template/components/team-board-view.tsx +114 -0
  129. package/template/components/team-client.tsx +93 -0
  130. package/template/components/team-list-view.tsx +62 -0
  131. package/template/components/team-page-header.tsx +92 -0
  132. package/template/components/team-table.tsx +525 -0
  133. package/template/components/templates/list-page.tsx +576 -0
  134. package/template/components/templates/primary-page-template.tsx +56 -0
  135. package/template/components/theme-color-sync.tsx +32 -0
  136. package/template/components/theme-provider.tsx +71 -0
  137. package/template/components/tinted-icon-disc.tsx +53 -0
  138. package/template/components/ui/ai-thinking-surface.tsx +121 -0
  139. package/template/components/ui/avatar.tsx +1 -0
  140. package/template/components/ui/badge.tsx +1 -0
  141. package/template/components/ui/banner.tsx +1 -0
  142. package/template/components/ui/breadcrumb.tsx +1 -0
  143. package/template/components/ui/button.tsx +1 -0
  144. package/template/components/ui/calendar.tsx +1 -0
  145. package/template/components/ui/card.tsx +1 -0
  146. package/template/components/ui/chart.tsx +1 -0
  147. package/template/components/ui/checkbox.tsx +1 -0
  148. package/template/components/ui/coach-mark.tsx +1 -0
  149. package/template/components/ui/collapsible.tsx +1 -0
  150. package/template/components/ui/command.tsx +1 -0
  151. package/template/components/ui/date-picker-field.tsx +1 -0
  152. package/template/components/ui/dialog.tsx +1 -0
  153. package/template/components/ui/dot-pattern.tsx +159 -0
  154. package/template/components/ui/drag-handle-grip.tsx +1 -0
  155. package/template/components/ui/drawer.tsx +1 -0
  156. package/template/components/ui/dropdown-menu.tsx +1 -0
  157. package/template/components/ui/field.tsx +1 -0
  158. package/template/components/ui/form.tsx +1 -0
  159. package/template/components/ui/input-group.tsx +1 -0
  160. package/template/components/ui/input-mask.tsx +1 -0
  161. package/template/components/ui/input.tsx +1 -0
  162. package/template/components/ui/kbd.tsx +1 -0
  163. package/template/components/ui/label.tsx +1 -0
  164. package/template/components/ui/leo-icon.tsx +726 -0
  165. package/template/components/ui/payment-card-fields.tsx +1 -0
  166. package/template/components/ui/popover.tsx +1 -0
  167. package/template/components/ui/radio-group.tsx +1 -0
  168. package/template/components/ui/select.tsx +1 -0
  169. package/template/components/ui/selection-tile-grid.tsx +1 -0
  170. package/template/components/ui/separator.tsx +1 -0
  171. package/template/components/ui/sheet.tsx +1 -0
  172. package/template/components/ui/sidebar.tsx +1 -0
  173. package/template/components/ui/skeleton.tsx +1 -0
  174. package/template/components/ui/sonner.tsx +1 -0
  175. package/template/components/ui/status-badge.tsx +1 -0
  176. package/template/components/ui/table.tsx +1 -0
  177. package/template/components/ui/tabs.tsx +1 -0
  178. package/template/components/ui/textarea.tsx +1 -0
  179. package/template/components/ui/tip.tsx +1 -0
  180. package/template/components/ui/toggle-group.tsx +1 -0
  181. package/template/components/ui/toggle-switch.tsx +1 -0
  182. package/template/components/ui/toggle.tsx +1 -0
  183. package/template/components/ui/tooltip.tsx +1 -0
  184. package/template/components/ui/view-segmented-control.tsx +1 -0
  185. package/template/components.json +27 -0
  186. package/template/contexts/chart-variant-context.tsx +35 -0
  187. package/template/contexts/command-menu-context.tsx +28 -0
  188. package/template/contexts/dashboard-view-context.tsx +35 -0
  189. package/template/contexts/product-context.tsx +38 -0
  190. package/template/contexts/system-banner-context.tsx +127 -0
  191. package/template/docs/command-menu-pattern.md +45 -0
  192. package/template/docs/data-views-pattern.md +160 -0
  193. package/template/ecosystem.config.cjs +20 -0
  194. package/template/eslint.config.mjs +18 -0
  195. package/template/fontawesome-subset.manifest.json +190 -0
  196. package/template/hooks/.gitkeep +0 -0
  197. package/template/hooks/use-app-theme.ts +1 -0
  198. package/template/hooks/use-coach-mark.ts +1 -0
  199. package/template/hooks/use-mobile.ts +1 -0
  200. package/template/hooks/use-mod-key-label.ts +1 -0
  201. package/template/lib/.gitkeep +0 -0
  202. package/template/lib/ask-leo-route-context.ts +133 -0
  203. package/template/lib/chart-keyboard-selection.test.ts +20 -0
  204. package/template/lib/chart-keyboard-selection.ts +17 -0
  205. package/template/lib/chart-line-dash.ts +16 -0
  206. package/template/lib/coach-mark-registry.ts +68 -0
  207. package/template/lib/command-menu-config.ts +127 -0
  208. package/template/lib/command-menu-search-data.ts +44 -0
  209. package/template/lib/conditional-rule-match.ts +32 -0
  210. package/template/lib/dashboard-customize-coach-mark.ts +18 -0
  211. package/template/lib/dashboard-layout-merge.ts +63 -0
  212. package/template/lib/data-list-display-options.ts +35 -0
  213. package/template/lib/data-list-persistence.ts +280 -0
  214. package/template/lib/data-list-view-surface.ts +58 -0
  215. package/template/lib/data-list-view.ts +29 -0
  216. package/template/lib/data-view-dashboard-storage.ts +101 -0
  217. package/template/lib/date-filter.ts +8 -0
  218. package/template/lib/dev-log.test.ts +28 -0
  219. package/template/lib/dev-log.ts +8 -0
  220. package/template/lib/editable-target.ts +10 -0
  221. package/template/lib/floating-sheet-panel.ts +72 -0
  222. package/template/lib/initials-from-name.ts +7 -0
  223. package/template/lib/list-page-table-properties.ts +52 -0
  224. package/template/lib/list-status-badges.ts +168 -0
  225. package/template/lib/logo-dev.ts +12 -0
  226. package/template/lib/mock/compliance-kpi.ts +61 -0
  227. package/template/lib/mock/compliance.ts +146 -0
  228. package/template/lib/mock/dashboard.ts +105 -0
  229. package/template/lib/mock/navigation.tsx +231 -0
  230. package/template/lib/mock/placements-kpi.ts +134 -0
  231. package/template/lib/mock/placements.ts +183 -0
  232. package/template/lib/mock/question-bank-kpi.ts +61 -0
  233. package/template/lib/mock/question-bank.ts +142 -0
  234. package/template/lib/mock/sites-directory.ts +16 -0
  235. package/template/lib/mock/sites-kpi.ts +25 -0
  236. package/template/lib/mock/team-kpi.ts +60 -0
  237. package/template/lib/mock/team.ts +118 -0
  238. package/template/lib/motion-ui.ts +17 -0
  239. package/template/lib/placement-board-card-layout.ts +79 -0
  240. package/template/lib/placement-lifecycle.ts +5 -0
  241. package/template/lib/row-height.ts +10 -0
  242. package/template/lib/stock-portrait.ts +11 -0
  243. package/template/lib/utils.test.ts +13 -0
  244. package/template/lib/utils.ts +1 -0
  245. package/template/next.config.mjs +15 -0
  246. package/template/package.json +83 -0
  247. package/template/postcss.config.mjs +8 -0
  248. package/template/public/.gitkeep +0 -0
  249. package/template/public/Illustration/Rotation.svg +74 -0
  250. package/template/public/avatars/user.svg +11 -0
  251. package/template/public/favicon/favicon.ico +0 -0
  252. package/template/public/favicon.ico +0 -0
  253. package/template/public/logos/exxat-one.svg +36 -0
  254. package/template/public/logos/exxat-prism.svg +39 -0
  255. package/template/public/mock-schools/emory.svg +4 -0
  256. package/template/public/mock-schools/rush.svg +4 -0
  257. package/template/scripts/fontawesome-subset-audit.mjs +190 -0
  258. package/template/scripts/pm2-startup-macos.sh +13 -0
  259. package/template/skills-lock.json +10 -0
  260. package/template/stores/app-store.ts +33 -0
  261. package/template/tests/setup.ts +1 -0
  262. package/template/tsconfig.json +35 -0
  263. package/template/types/react-payment-inputs.d.ts +19 -0
  264. package/template/vitest.config.ts +18 -0
@@ -0,0 +1,394 @@
1
+ "use client"
2
+
3
+ /**
4
+ * SecondaryNav — two-panel contextual navigation
5
+ *
6
+ * Layout:
7
+ * ┌──────┬──────────────────────────┐
8
+ * │ Rail │ Content panel │
9
+ * │ (52) │ (200–240px) │
10
+ * │ │ Section heading │
11
+ * │ ◉ │ · Nav item │
12
+ * │ ○ │ · Nav item (active) │
13
+ * │ ○ │ ───────────────── │
14
+ * │ │ Section heading │
15
+ * │ │ · Nav item │
16
+ * └──────┴──────────────────────────┘
17
+ *
18
+ * Usage:
19
+ * <SecondaryNav sections={SECTIONS} />
20
+ *
21
+ * Or use composed pieces:
22
+ * <SecondaryNavRail sections={…} activeSection={…} onSectionChange={…} />
23
+ * <SecondaryNavPanel section={…} />
24
+ */
25
+
26
+ import * as React from "react"
27
+ import { usePathname } from "next/navigation"
28
+ import { cn } from "@/lib/utils"
29
+ import {
30
+ Tooltip,
31
+ TooltipContent,
32
+ TooltipProvider,
33
+ TooltipTrigger,
34
+ } from "@/components/ui/tooltip"
35
+
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ // Types
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+
40
+ export interface SecondaryNavLink {
41
+ /** Unique key */
42
+ key: string
43
+ label: string
44
+ href: string
45
+ icon?: string
46
+ /** Badge count shown on link */
47
+ count?: number
48
+ /** If true, item is rendered as a section divider label (not a link) */
49
+ isSectionHeader?: boolean
50
+ }
51
+
52
+ export interface SecondaryNavSectionAction {
53
+ /** Tooltip / aria-label */
54
+ label: string
55
+ /** FontAwesome icon class, e.g. "fa-plus" */
56
+ icon: string
57
+ onClick: () => void
58
+ }
59
+
60
+ export interface SecondaryNavSection {
61
+ /** Unique key — used to identify the active section */
62
+ key: string
63
+ /** Tooltip shown on rail icon */
64
+ label: string
65
+ /** FontAwesome icon class, e.g. "fa-users" */
66
+ icon: string
67
+ /** Solid icon used when section is active */
68
+ iconActive?: string
69
+ /** Flat list of links (use isSectionHeader=true for dividers) */
70
+ links: SecondaryNavLink[]
71
+ /** When true, a search input is shown above the list and filters link labels */
72
+ searchable?: boolean
73
+ /** Placeholder for the search input */
74
+ searchPlaceholder?: string
75
+ /** Optional primary action rendered next to the section title (e.g. Add) */
76
+ action?: SecondaryNavSectionAction
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+ // RailButton — single icon in the narrow left rail
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+
83
+ function RailButton({
84
+ section,
85
+ isActive,
86
+ onClick,
87
+ }: {
88
+ section: SecondaryNavSection
89
+ isActive: boolean
90
+ onClick: () => void
91
+ }) {
92
+ return (
93
+ <Tooltip>
94
+ <TooltipTrigger asChild>
95
+ <button
96
+ type="button"
97
+ aria-label={section.label}
98
+ aria-current={isActive ? "true" : undefined}
99
+ onClick={onClick}
100
+ className={cn(
101
+ "relative flex items-center justify-center size-9 rounded-lg transition-all",
102
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
103
+ isActive
104
+ ? [
105
+ "text-[var(--brand-color)]",
106
+ "bg-[color-mix(in_oklch,var(--background)_88%,var(--brand-color)_12%)]",
107
+ ].join(" ")
108
+ : "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-strong"
109
+ )}
110
+ >
111
+ {/* Active left pip */}
112
+ {isActive && (
113
+ <span
114
+ aria-hidden="true"
115
+ className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full bg-[var(--brand-color)]"
116
+ />
117
+ )}
118
+ <i
119
+ className={cn(
120
+ isActive && section.iconActive
121
+ ? `fa-solid ${section.iconActive}`
122
+ : `fa-light ${section.icon}`,
123
+ "text-[15px]"
124
+ )}
125
+ aria-hidden="true"
126
+ />
127
+ </button>
128
+ </TooltipTrigger>
129
+ <TooltipContent side="right" sideOffset={6}>
130
+ {section.label}
131
+ </TooltipContent>
132
+ </Tooltip>
133
+ )
134
+ }
135
+
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+ // NavLink — single item in the content panel
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+
140
+ function NavLink({ link }: { link: SecondaryNavLink }) {
141
+ const pathname = usePathname()
142
+ const isActive = pathname === link.href || pathname.startsWith(link.href + "/")
143
+
144
+ if (link.isSectionHeader) {
145
+ return (
146
+ <li role="presentation">
147
+ <span className="block px-3 pt-3 pb-0.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 select-none">
148
+ {link.label}
149
+ </span>
150
+ </li>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <li>
156
+ <a
157
+ href={link.href}
158
+ aria-current={isActive ? "page" : undefined}
159
+ className={cn(
160
+ "group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors",
161
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
162
+ isActive
163
+ ? [
164
+ "font-medium text-[var(--brand-color)]",
165
+ "bg-[color-mix(in_oklch,var(--background)_88%,var(--brand-color)_12%)]",
166
+ ].join(" ")
167
+ : "text-foreground/80 hover:text-interactive-hover-foreground hover:bg-interactive-hover-medium"
168
+ )}
169
+ >
170
+ {link.icon && (
171
+ <span className="size-4 shrink-0 flex items-center justify-center">
172
+ <i
173
+ className={cn(
174
+ isActive ? `fa-solid ${link.icon}` : `fa-light ${link.icon}`,
175
+ "text-[13px]"
176
+ )}
177
+ aria-hidden="true"
178
+ />
179
+ </span>
180
+ )}
181
+ <span className="flex-1 min-w-0 truncate">{link.label}</span>
182
+ {link.count !== undefined && (
183
+ <span
184
+ className={cn(
185
+ "shrink-0 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-xs font-medium px-1",
186
+ isActive
187
+ ? "bg-[var(--brand-color)] text-white"
188
+ : "bg-muted text-muted-foreground"
189
+ )}
190
+ >
191
+ {link.count}
192
+ </span>
193
+ )}
194
+ </a>
195
+ </li>
196
+ )
197
+ }
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // SecondaryNavRail — exported if consumers want to compose manually
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+
203
+ export function SecondaryNavRail({
204
+ sections,
205
+ activeSection,
206
+ onSectionChange,
207
+ className,
208
+ }: {
209
+ sections: SecondaryNavSection[]
210
+ activeSection: string
211
+ onSectionChange: (key: string) => void
212
+ className?: string
213
+ }) {
214
+ return (
215
+ <nav
216
+ aria-label="Section navigation"
217
+ className={cn(
218
+ "flex flex-col items-center gap-1 pt-3 pb-2 px-1.5 w-[52px] shrink-0 border-r border-border",
219
+ className
220
+ )}
221
+ >
222
+ <TooltipProvider delayDuration={200}>
223
+ {sections.map(section => (
224
+ <RailButton
225
+ key={section.key}
226
+ section={section}
227
+ isActive={activeSection === section.key}
228
+ onClick={() => onSectionChange(section.key)}
229
+ />
230
+ ))}
231
+ </TooltipProvider>
232
+ </nav>
233
+ )
234
+ }
235
+
236
+ // ─────────────────────────────────────────────────────────────────────────────
237
+ // SecondaryNavPanel — exported for manual composition
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ export function SecondaryNavPanel({
241
+ section,
242
+ className,
243
+ }: {
244
+ section: SecondaryNavSection
245
+ className?: string
246
+ }) {
247
+ const [query, setQuery] = React.useState("")
248
+ const q = query.trim().toLowerCase()
249
+
250
+ const visibleLinks = React.useMemo(() => {
251
+ if (!section.searchable || !q) return section.links
252
+ // Filter out non-header items that don't match; drop headers whose group becomes empty
253
+ const kept: SecondaryNavLink[] = []
254
+ let pendingHeader: SecondaryNavLink | null = null
255
+ let groupHasMatch = false
256
+ for (const link of section.links) {
257
+ if (link.isSectionHeader) {
258
+ if (pendingHeader && groupHasMatch) kept.push(pendingHeader)
259
+ pendingHeader = link
260
+ groupHasMatch = false
261
+ continue
262
+ }
263
+ if (link.label.toLowerCase().includes(q)) {
264
+ if (pendingHeader && !groupHasMatch) {
265
+ kept.push(pendingHeader)
266
+ groupHasMatch = true
267
+ } else if (!pendingHeader) {
268
+ groupHasMatch = true
269
+ }
270
+ kept.push(link)
271
+ }
272
+ }
273
+ if (pendingHeader && groupHasMatch) {
274
+ // already pushed above when first match in group — nothing to do
275
+ }
276
+ return kept
277
+ }, [section.links, section.searchable, q])
278
+
279
+ return (
280
+ <nav
281
+ aria-label={`${section.label} navigation`}
282
+ className={cn("flex flex-col min-w-[200px] max-w-[240px] pt-3 pb-2", className)}
283
+ >
284
+ {/* Section title + action */}
285
+ <div className="flex items-center justify-between gap-2 px-3 pb-2">
286
+ <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 select-none">
287
+ {section.label}
288
+ </p>
289
+ {section.action && (
290
+ <button
291
+ type="button"
292
+ aria-label={section.action.label}
293
+ title={section.action.label}
294
+ onClick={section.action.onClick}
295
+ className={cn(
296
+ "inline-flex size-6 items-center justify-center rounded-md -mr-1",
297
+ "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-strong",
298
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
299
+ )}
300
+ >
301
+ <i className={cn("fa-light", section.action.icon, "text-[13px]")} aria-hidden="true" />
302
+ </button>
303
+ )}
304
+ </div>
305
+
306
+ {/* Search */}
307
+ {section.searchable && (
308
+ <div className="px-2 pb-2">
309
+ <div className="relative">
310
+ <i
311
+ className="fa-light fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-[11px] text-muted-foreground pointer-events-none"
312
+ aria-hidden="true"
313
+ />
314
+ <input
315
+ type="search"
316
+ value={query}
317
+ onChange={e => setQuery(e.target.value)}
318
+ placeholder={section.searchPlaceholder ?? "Search"}
319
+ aria-label={`Search ${section.label}`}
320
+ className={cn(
321
+ "w-full h-7 pl-7 pr-2 rounded-md text-xs bg-background border border-border",
322
+ "placeholder:text-muted-foreground/70",
323
+ "focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
324
+ )}
325
+ />
326
+ </div>
327
+ </div>
328
+ )}
329
+
330
+ <ul role="list" className="flex flex-col gap-0.5 px-1.5">
331
+ {visibleLinks.map(link => (
332
+ <NavLink key={link.key} link={link} />
333
+ ))}
334
+ {section.searchable && q && visibleLinks.length === 0 && (
335
+ <li className="px-3 py-2 text-xs text-muted-foreground">No results</li>
336
+ )}
337
+ </ul>
338
+ </nav>
339
+ )
340
+ }
341
+
342
+ // ─────────────────────────────────────────────────────────────────────────────
343
+ // SecondaryNav — composed two-panel component (default export)
344
+ // ─────────────────────────────────────────────────────────────────────────────
345
+
346
+ export interface SecondaryNavProps {
347
+ sections: SecondaryNavSection[]
348
+ /** Which section key is active by default — defaults to first section */
349
+ defaultSection?: string
350
+ className?: string
351
+ /** Called when active section changes */
352
+ onSectionChange?: (key: string) => void
353
+ }
354
+
355
+ export function SecondaryNav({
356
+ sections,
357
+ defaultSection,
358
+ className,
359
+ onSectionChange,
360
+ }: SecondaryNavProps) {
361
+ const [activeSection, setActiveSection] = React.useState(
362
+ defaultSection ?? sections[0]?.key ?? ""
363
+ )
364
+
365
+ const currentSection = sections.find(s => s.key === activeSection) ?? sections[0]
366
+
367
+ function handleSectionChange(key: string) {
368
+ setActiveSection(key)
369
+ onSectionChange?.(key)
370
+ }
371
+
372
+ if (!currentSection) return null
373
+
374
+ return (
375
+ <div
376
+ className={cn(
377
+ "flex h-full border-r border-border bg-sidebar",
378
+ className
379
+ )}
380
+ >
381
+ {/* Left icon rail — only shown when multiple sections */}
382
+ {sections.length > 1 && (
383
+ <SecondaryNavRail
384
+ sections={sections}
385
+ activeSection={activeSection}
386
+ onSectionChange={handleSectionChange}
387
+ />
388
+ )}
389
+
390
+ {/* Right content panel */}
391
+ <SecondaryNavPanel section={currentSection} />
392
+ </div>
393
+ )
394
+ }
@@ -0,0 +1,239 @@
1
+ "use client"
2
+
3
+ /**
4
+ * SecondaryPanel — nested sidebar panel that appears between the icon-rail
5
+ * sidebar and the main content. When active, the main sidebar collapses to
6
+ * icon-only mode; when dismissed, the sidebar expands back.
7
+ *
8
+ * Pattern: VS Code / Figma icon-rail + detail panel.
9
+ */
10
+
11
+ import * as React from "react"
12
+ import { cn } from "@/lib/utils"
13
+ import { useSidebar } from "@/components/ui/sidebar"
14
+ import { Tip } from "@/components/ui/tip"
15
+ import { Button } from "@/components/ui/button"
16
+ import {
17
+ InputGroup,
18
+ InputGroupAddon,
19
+ InputGroupInput,
20
+ } from "@/components/ui/input-group"
21
+
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ // Context
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ interface SecondaryPanelContextValue {
27
+ /** Currently active panel id, or null if none */
28
+ activePanel: string | null
29
+ /** Open a panel by id */
30
+ openPanel: (id: string) => void
31
+ /** Close the panel */
32
+ closePanel: () => void
33
+ }
34
+
35
+ const SecondaryPanelContext = React.createContext<SecondaryPanelContextValue>({
36
+ activePanel: null,
37
+ openPanel: () => {},
38
+ closePanel: () => {},
39
+ })
40
+
41
+ export function useSecondaryPanel() {
42
+ return React.useContext(SecondaryPanelContext)
43
+ }
44
+
45
+ export function SecondaryPanelProvider({ children }: { children: React.ReactNode }) {
46
+ const [activePanel, setActivePanel] = React.useState<string | null>(null)
47
+ const { setOpen } = useSidebar()
48
+
49
+ const openPanel = React.useCallback((id: string) => {
50
+ setActivePanel(id)
51
+ setOpen(false) // collapse main sidebar to icon rail
52
+ }, [setOpen])
53
+
54
+ const closePanel = React.useCallback(() => {
55
+ setActivePanel(null)
56
+ setOpen(true) // expand main sidebar back
57
+ }, [setOpen])
58
+
59
+ const value = React.useMemo(() => ({
60
+ activePanel, openPanel, closePanel,
61
+ }), [activePanel, openPanel, closePanel])
62
+
63
+ return (
64
+ <SecondaryPanelContext.Provider value={value}>
65
+ {children}
66
+ </SecondaryPanelContext.Provider>
67
+ )
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // Panel content — Rotations
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ const ROTATION_ITEMS = [
75
+ { id: "all", label: "All Rotations", icon: "fa-folder", iconActive: "fa-folder", meta: "12 active" },
76
+ { id: "rotation-1", label: "Clinical Nursing — Fall 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "8 students" },
77
+ { id: "rotation-2", label: "PT Fieldwork — Spring 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "6 students" },
78
+ { id: "rotation-3", label: "OT Level II — Summer 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "4 students" },
79
+ ]
80
+
81
+ function RotationsPanel() {
82
+ const { closePanel } = useSecondaryPanel()
83
+ const [activeRotation, setActiveRotation] = React.useState("all")
84
+ const [query, setQuery] = React.useState("")
85
+
86
+ const q = query.trim().toLowerCase()
87
+ const filtered = q
88
+ ? ROTATION_ITEMS.filter(i => i.label.toLowerCase().includes(q))
89
+ : ROTATION_ITEMS
90
+
91
+ return (
92
+ <>
93
+ <div className="flex items-center justify-between gap-2 px-4 pt-4 pb-2">
94
+ <h2
95
+ className="text-xl font-semibold leading-tight text-sidebar-foreground"
96
+ style={{ fontFamily: "var(--font-heading)" }}
97
+ >
98
+ Rotations
99
+ </h2>
100
+ <div className="flex items-center gap-1">
101
+ <Tip label="Add rotation" side="bottom">
102
+ <Button
103
+ type="button"
104
+ size="icon"
105
+ variant="ghost"
106
+ onClick={() => { /* hook up create flow */ }}
107
+ aria-label="Add rotation"
108
+ >
109
+ <i className="fa-light fa-plus" aria-hidden="true" />
110
+ </Button>
111
+ </Tip>
112
+ <Tip label="Close panel" side="bottom">
113
+ <Button
114
+ type="button"
115
+ size="icon"
116
+ variant="ghost"
117
+ onClick={closePanel}
118
+ aria-label="Close panel"
119
+ >
120
+ <i className="fa-light fa-xmark" aria-hidden="true" />
121
+ </Button>
122
+ </Tip>
123
+ </div>
124
+ </div>
125
+ <div className="px-4 pb-2">
126
+ <InputGroup className="h-8 min-h-8">
127
+ <InputGroupAddon>
128
+ <i className="fa-light fa-magnifying-glass size-4 text-[13px]" aria-hidden="true" />
129
+ </InputGroupAddon>
130
+ <InputGroupInput
131
+ type="search"
132
+ value={query}
133
+ onChange={e => setQuery(e.target.value)}
134
+ placeholder="Search rotations"
135
+ aria-label="Search rotations"
136
+ className="text-sm pe-3"
137
+ />
138
+ </InputGroup>
139
+ </div>
140
+ <ul className="flex-1 space-y-0.5 px-4 pb-4" role="listbox">
141
+ {filtered.length === 0 && (
142
+ <li className="py-2 text-xs text-muted-foreground">No results</li>
143
+ )}
144
+ {filtered.map(item => {
145
+ const isActive = item.id === activeRotation
146
+ return (
147
+ <li key={item.id}>
148
+ <Tip label={item.label} side="right">
149
+ <button
150
+ type="button"
151
+ role="option"
152
+ aria-selected={isActive}
153
+ onClick={() => setActiveRotation(item.id)}
154
+ className={cn(
155
+ // Match primary `SidebarMenuButton`: text-sm, compact padding, sidebar tokens
156
+ "flex w-full items-start gap-2 rounded-md p-2 text-left text-sm transition-colors",
157
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
158
+ isActive
159
+ ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
160
+ : "text-sidebar-foreground hover:bg-sidebar-accent/50",
161
+ )}
162
+ >
163
+ <i
164
+ className={cn(
165
+ "mt-0.5 size-4 shrink-0 text-center text-[13px] leading-none",
166
+ isActive ? `fa-solid ${item.iconActive}` : `fa-light ${item.icon}`,
167
+ )}
168
+ aria-hidden="true"
169
+ />
170
+ <div className="min-w-0 flex-1">
171
+ <span className="block whitespace-normal break-words leading-snug">
172
+ {item.label}
173
+ </span>
174
+ {item.meta && (
175
+ <span className="mt-0.5 block whitespace-normal break-words text-xs leading-snug text-muted-foreground">
176
+ {item.meta}
177
+ </span>
178
+ )}
179
+ </div>
180
+ </button>
181
+ </Tip>
182
+ </li>
183
+ )
184
+ })}
185
+ </ul>
186
+ </>
187
+ )
188
+ }
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────────
191
+ // SecondaryPanel — the actual rendered panel
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+
194
+ const PANELS: Record<string, React.FC> = {
195
+ rotations: RotationsPanel,
196
+ }
197
+
198
+ export function SecondaryPanel() {
199
+ const { activePanel } = useSecondaryPanel()
200
+ const PanelContent = activePanel ? PANELS[activePanel] : null
201
+
202
+ return (
203
+ <nav
204
+ aria-label="Secondary navigation"
205
+ data-state={activePanel ? "open" : "closed"}
206
+ className={cn(
207
+ "flex flex-col overflow-hidden",
208
+ "transition-[width,margin,opacity] duration-200 ease-linear",
209
+ activePanel
210
+ ? "w-56 shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative h-[min(calc(100svh-2rem),800px)] md:sticky md:top-2 md:h-[min(calc(100svh-1rem),800px)]"
211
+ : "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none"
212
+ )}
213
+ style={activePanel ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
214
+ >
215
+ <div
216
+ className={cn(
217
+ "flex flex-1 flex-col overflow-y-auto",
218
+ activePanel ? "min-w-0" : "hidden min-w-0 w-0 p-0"
219
+ )}
220
+ >
221
+ {PanelContent && <PanelContent />}
222
+ </div>
223
+ </nav>
224
+ )
225
+ }
226
+
227
+ // ─────────────────────────────────────────────────────────────────────────────
228
+ // Auto-open hook — pages call this to show/hide a panel on mount/unmount
229
+ // ─────────────────────────────────────────────────────────────────────────────
230
+
231
+ export function useAutoPanel(panelId: string) {
232
+ const { openPanel, closePanel } = useSecondaryPanel()
233
+
234
+ React.useEffect(() => {
235
+ openPanel(panelId)
236
+ return () => { closePanel() }
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [panelId])
239
+ }