@exxatdesignux/ui 0.0.6 → 0.0.7

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,901 @@
1
+ "use client"
2
+
3
+ /**
4
+ * AppSidebar — single-column nav matching the design reference:
5
+ * Exxat One header, primary links, "Documents" group, utilities, user.
6
+ *
7
+ * Collapsed (icon) chrome is driven only by CSS (`group-data-[collapsible=icon]:…`)
8
+ * on the ancestor from `ui/sidebar` — the same DOM is always rendered so Radix
9
+ * `useId()` order matches between SSR and hydration (fixes downstream menus).
10
+ */
11
+
12
+ import * as React from "react"
13
+ import Link from "next/link"
14
+ import { usePathname } from "next/navigation"
15
+ import { motion, useReducedMotion } from "motion/react"
16
+
17
+ import {
18
+ Collapsible,
19
+ CollapsibleContent,
20
+ CollapsibleTrigger,
21
+ } from "@/components/ui/collapsible"
22
+ import {
23
+ DropdownMenu,
24
+ DropdownMenuContent,
25
+ DropdownMenuItem,
26
+ DropdownMenuLabel,
27
+ DropdownMenuSeparator,
28
+ DropdownMenuTrigger,
29
+ } from "@/components/ui/dropdown-menu"
30
+ import {
31
+ Popover,
32
+ PopoverContent,
33
+ PopoverTrigger,
34
+ } from "@/components/ui/popover"
35
+ import {
36
+ Sidebar,
37
+ SidebarContent,
38
+ SidebarFooter,
39
+ SidebarGroup,
40
+ SidebarGroupContent,
41
+ SidebarGroupLabel,
42
+ SidebarHeader,
43
+ SidebarMenu,
44
+ SidebarMenuButton,
45
+ SidebarMenuItem,
46
+ SidebarMenuBadge,
47
+ SidebarMenuSub,
48
+ SidebarMenuSubButton,
49
+ SidebarMenuSubItem,
50
+ useSidebar,
51
+ } from "@/components/ui/sidebar"
52
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
53
+ import { Badge } from "@/components/ui/badge"
54
+ import { StatusBadge } from "@/components/ui/status-badge"
55
+ import {
56
+ Tooltip,
57
+ TooltipContent,
58
+ TooltipTrigger,
59
+ } from "@/components/ui/tooltip"
60
+ import { cn } from "@/lib/utils"
61
+ import { Button } from "@/components/ui/button"
62
+ import { Tip } from "@/components/ui/tip"
63
+ import { requestOpenCommandMenu } from "@/components/command-menu"
64
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
65
+ import { useModKeyLabel } from "@/hooks/use-mod-key-label"
66
+ import { useProduct, type Product } from "@/contexts/product-context"
67
+ import { NavUser } from "@/components/nav-user"
68
+ import { useSecondaryPanel } from "@/components/secondary-panel"
69
+ import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
70
+ import { motionHeaderEnter } from "@/lib/motion-ui"
71
+ import {
72
+ NAV_DOCUMENTS,
73
+ NAV_DOCUMENTS_LABEL,
74
+ NAV_PRIMARY,
75
+ NAV_SCHOOL_DEFAULT,
76
+ NAV_PROGRAM_DEFAULT,
77
+ NAV_QUICK_ACTIONS,
78
+ NAV_SCHOOLS,
79
+ NAV_SECONDARY,
80
+ NAV_USER,
81
+ type NavLinkItem,
82
+ type NavSecondaryItem,
83
+ type NavSchool,
84
+ type NavProgram,
85
+ } from "@/lib/mock/navigation"
86
+
87
+ /** Path segment of a nav URL (strip `#fragment` for matching). */
88
+ function navUrlPath(url: string): string {
89
+ if (!url || url === "#") return ""
90
+ const i = url.indexOf("#")
91
+ return i === -1 ? url : url.slice(0, i)
92
+ }
93
+
94
+ function isNavActive(pathname: string, url: string): boolean {
95
+ const pathOnly = navUrlPath(url)
96
+ if (!pathOnly || pathOnly === "#") return false
97
+ if (pathOnly === "/") return pathname === "/"
98
+ if (pathname === pathOnly) return true
99
+ // Design system library — active on hub and detail routes.
100
+ if (pathOnly === "/library") {
101
+ return pathname.startsWith("/library/")
102
+ }
103
+ if (pathOnly.startsWith("/library/")) {
104
+ return pathname === pathOnly
105
+ }
106
+ return pathname.startsWith(`${pathOnly}/`)
107
+ }
108
+
109
+ function useLocationHash(): string {
110
+ const [hash, setHash] = React.useState("")
111
+ React.useEffect(() => {
112
+ const read = () => setHash(typeof window !== "undefined" ? window.location.hash : "")
113
+ read()
114
+ window.addEventListener("hashchange", read)
115
+ return () => window.removeEventListener("hashchange", read)
116
+ }, [])
117
+ return hash
118
+ }
119
+
120
+ /** Sub-item active — catalog detail routes, hash fragments, or duplicate hub URLs (Rotations). */
121
+ function isCollapsibleChildActive(
122
+ pathname: string,
123
+ parent: NavLinkItem,
124
+ child: NavLinkItem,
125
+ locationHash: string
126
+ ): boolean {
127
+ const children = parent.children
128
+ if (!children?.length) return isNavActive(pathname, child.url)
129
+
130
+ const hasHashChild = children.some(c => c.url.includes("#"))
131
+ if (hasHashChild) {
132
+ const h = locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
133
+ const childHash = child.url.includes("#") ? child.url.split("#")[1] : ""
134
+ if (parent.primaryHubChildKey && child.key === parent.primaryHubChildKey) {
135
+ return h === ""
136
+ }
137
+ if (childHash) {
138
+ return h === childHash
139
+ }
140
+ return false
141
+ }
142
+
143
+ if (!isNavActive(pathname, child.url)) return false
144
+
145
+ const urls = children.map(c => c.url)
146
+ const allSameUrl = urls.length > 1 && urls.every(u => u === urls[0])
147
+ if (allSameUrl) {
148
+ if (parent.primaryHubChildKey) {
149
+ return child.key === parent.primaryHubChildKey
150
+ }
151
+ return false
152
+ }
153
+ return true
154
+ }
155
+
156
+ /** Accessible suffix for sidebar badges (badge is rendered outside the link node). */
157
+ function badgeAccessibleSuffix(badge: number | string): string {
158
+ if (typeof badge === "number") return `${badge} items`
159
+ return String(badge)
160
+ }
161
+
162
+ /** Child row for expandable nav items — shared by inline sub-menu and collapsed-rail popover. */
163
+ function SidebarNavChildLink({
164
+ parent,
165
+ child,
166
+ pathname,
167
+ locationHash,
168
+ onNavigate,
169
+ linkClassName,
170
+ }: {
171
+ parent: NavLinkItem
172
+ child: NavLinkItem
173
+ pathname: string
174
+ locationHash: string
175
+ onNavigate?: () => void
176
+ /** Popover uses surface tokens; inline sub-menu uses `SidebarMenuSubButton`. */
177
+ linkClassName?: string
178
+ }) {
179
+ const { openPanel } = useSecondaryPanel()
180
+ const childActive = isCollapsibleChildActive(pathname, parent, child, locationHash)
181
+ const childPath = navUrlPath(child.url)
182
+
183
+ return (
184
+ <Link
185
+ href={child.url}
186
+ className={cn("flex min-w-0 items-center gap-2", linkClassName)}
187
+ aria-current={childActive ? "page" : undefined}
188
+ onClick={e => {
189
+ onNavigate?.()
190
+ if (
191
+ parent.secondaryPanel &&
192
+ pathname === childPath &&
193
+ !child.url.includes("#")
194
+ ) {
195
+ e.preventDefault()
196
+ openPanel(parent.secondaryPanel)
197
+ }
198
+ }}
199
+ >
200
+ <span className="size-4 shrink-0 inline-flex items-center justify-center" aria-hidden="true">
201
+ {child.icon}
202
+ </span>
203
+ <span className="min-w-0 flex-1 truncate">{child.title}</span>
204
+ </Link>
205
+ )
206
+ }
207
+
208
+ /**
209
+ * CollapsibleNavItem — isolated component so each collapsible has its own
210
+ * controlled `open` state initialised in useEffect. This avoids the Radix
211
+ * hydration mismatch caused by `defaultOpen` resolving differently on the
212
+ * server (SSR) vs the client (router not yet available).
213
+ */
214
+ function CollapsibleNavItem({ item, pathname }: { item: NavLinkItem; pathname: string }) {
215
+ const locationHash = useLocationHash()
216
+ const isActive = isNavActive(pathname, item.url)
217
+ const isAnyChildActive =
218
+ item.children?.some(c => isCollapsibleChildActive(pathname, item, c, locationHash)) ?? false
219
+ const { state, isMobile } = useSidebar()
220
+ const [open, setOpen] = React.useState(isAnyChildActive)
221
+ const [flyoutOpen, setFlyoutOpen] = React.useState(false)
222
+ const flyoutTitleId = React.useId()
223
+ const iconRailCollapsed = state === "collapsed" && !isMobile
224
+ const showActiveStyle = isActive || isAnyChildActive
225
+ const triggerIcon =
226
+ showActiveStyle && item.iconActive ? item.iconActive : item.icon
227
+
228
+ React.useEffect(() => {
229
+ setOpen(isAnyChildActive)
230
+ }, [pathname, isAnyChildActive, locationHash])
231
+
232
+ React.useEffect(() => {
233
+ setFlyoutOpen(false)
234
+ }, [pathname])
235
+
236
+ if (!item.children?.length) return null
237
+
238
+ /** Icon rail: sub-list is hidden — open a flyout. Also avoids `CollapsibleTrigger asChild` on `SidebarMenuButton` with `tooltip` (extra `Tooltip` root breaks Radix `Slot`). */
239
+ if (iconRailCollapsed) {
240
+ return (
241
+ <SidebarMenuItem>
242
+ <Popover open={flyoutOpen} onOpenChange={setFlyoutOpen}>
243
+ <Tooltip>
244
+ <TooltipTrigger asChild>
245
+ <PopoverTrigger asChild>
246
+ <SidebarMenuButton
247
+ isActive={showActiveStyle}
248
+ aria-haspopup="dialog"
249
+ aria-label={`${item.title} — open subpages`}
250
+ >
251
+ <span
252
+ className={cn(
253
+ "size-4 shrink-0 flex items-center justify-center",
254
+ showActiveStyle &&
255
+ "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
256
+ )}
257
+ aria-hidden="true"
258
+ >
259
+ {triggerIcon}
260
+ </span>
261
+ <span className="sr-only">{item.title}</span>
262
+ </SidebarMenuButton>
263
+ </PopoverTrigger>
264
+ </TooltipTrigger>
265
+ <TooltipContent side="right" align="center">
266
+ {item.title}
267
+ </TooltipContent>
268
+ </Tooltip>
269
+ <PopoverContent
270
+ className="w-64 p-1"
271
+ side="right"
272
+ align="start"
273
+ sideOffset={8}
274
+ aria-labelledby={flyoutTitleId}
275
+ >
276
+ <h2 id={flyoutTitleId} className="sr-only">
277
+ {item.title}
278
+ </h2>
279
+ <ul className="flex flex-col gap-0.5" role="list">
280
+ {item.children.map(child => {
281
+ const childActive = isCollapsibleChildActive(pathname, item, child, locationHash)
282
+ return (
283
+ <li key={child.key}>
284
+ <SidebarNavChildLink
285
+ parent={item}
286
+ child={child}
287
+ pathname={pathname}
288
+ locationHash={locationHash}
289
+ onNavigate={() => setFlyoutOpen(false)}
290
+ linkClassName={cn(
291
+ "flex min-h-8 w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none ring-ring",
292
+ "text-popover-foreground hover:bg-accent hover:text-accent-foreground",
293
+ "focus-visible:ring-2",
294
+ childActive && "bg-accent font-medium text-accent-foreground",
295
+ )}
296
+ />
297
+ </li>
298
+ )
299
+ })}
300
+ </ul>
301
+ </PopoverContent>
302
+ </Popover>
303
+ </SidebarMenuItem>
304
+ )
305
+ }
306
+
307
+ return (
308
+ <Collapsible open={open} onOpenChange={setOpen} asChild>
309
+ <SidebarMenuItem>
310
+ <Tooltip>
311
+ <TooltipTrigger asChild>
312
+ <CollapsibleTrigger asChild>
313
+ <SidebarMenuButton isActive={showActiveStyle}>
314
+ <span
315
+ key={showActiveStyle ? "active" : "idle"}
316
+ className={cn(
317
+ "size-4 shrink-0 flex items-center justify-center",
318
+ showActiveStyle &&
319
+ "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
320
+ )}
321
+ aria-hidden="true"
322
+ >
323
+ {triggerIcon}
324
+ </span>
325
+ <span>{item.title}</span>
326
+ <i
327
+ className="fa-light fa-chevron-right ml-auto text-xs text-current transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
328
+ aria-hidden="true"
329
+ />
330
+ </SidebarMenuButton>
331
+ </CollapsibleTrigger>
332
+ </TooltipTrigger>
333
+ <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile}>
334
+ {item.title}
335
+ </TooltipContent>
336
+ </Tooltip>
337
+ <CollapsibleContent className="group-data-[collapsible=icon]:hidden">
338
+ <SidebarMenuSub>
339
+ {item.children.map(child => {
340
+ const childActive = isCollapsibleChildActive(pathname, item, child, locationHash)
341
+ return (
342
+ <SidebarMenuSubItem key={child.key}>
343
+ <SidebarMenuSubButton asChild isActive={childActive}>
344
+ <SidebarNavChildLink
345
+ parent={item}
346
+ child={child}
347
+ pathname={pathname}
348
+ locationHash={locationHash}
349
+ />
350
+ </SidebarMenuSubButton>
351
+ </SidebarMenuSubItem>
352
+ )
353
+ })}
354
+ </SidebarMenuSub>
355
+ </CollapsibleContent>
356
+ </SidebarMenuItem>
357
+ </Collapsible>
358
+ )
359
+ }
360
+
361
+ function NavLinkItems({ items, pathname }: { items: NavLinkItem[]; pathname: string }) {
362
+ return (
363
+ <>
364
+ {items.map(item => {
365
+ // Large child sets (>40) skip the collapsible/flyout pattern and navigate
366
+ // to a full page instead — prevents overwhelming the sidebar.
367
+ const childCount = item.children?.length ?? 0
368
+ if (childCount > 0 && childCount <= 40) {
369
+ return <CollapsibleNavItem key={item.key} item={item} pathname={pathname} />
370
+ }
371
+
372
+ const isActive = isNavActive(pathname, item.url)
373
+ return (
374
+ <SidebarMenuItem key={item.key}>
375
+ <SidebarMenuButton asChild isActive={isActive} tooltip={item.title}>
376
+ <Link
377
+ href={item.url}
378
+ aria-current={isActive ? "page" : undefined}
379
+ aria-label={
380
+ item.badge !== undefined
381
+ ? `${item.title}, ${badgeAccessibleSuffix(item.badge)}`
382
+ : undefined
383
+ }
384
+ >
385
+ <span
386
+ key={isActive ? "active" : "idle"}
387
+ className={cn(
388
+ "size-4 shrink-0 flex items-center justify-center",
389
+ isActive &&
390
+ "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
391
+ )}
392
+ aria-hidden="true"
393
+ >
394
+ {isActive && item.iconActive ? item.iconActive : item.icon}
395
+ </span>
396
+ <span>{item.title}</span>
397
+ </Link>
398
+ </SidebarMenuButton>
399
+ {item.badge !== undefined && (
400
+ <>
401
+ {/* Full badge — visible when sidebar is expanded */}
402
+ <SidebarMenuBadge aria-hidden="true">
403
+ {typeof item.badge === "number" ? (
404
+ <Badge className="h-4 min-w-4 px-1 text-xs leading-none font-semibold rounded-full tabular-nums border-transparent bg-red-600 text-white hover:bg-red-600">
405
+ {item.badge}
406
+ </Badge>
407
+ ) : item.badge === "New" ? (
408
+ <StatusBadge status="new" />
409
+ ) : item.badge === "Beta" ? (
410
+ <StatusBadge status="beta" />
411
+ ) : (
412
+ <Badge className="h-4 px-1.5 text-xs leading-none font-semibold rounded-full">
413
+ {item.badge}
414
+ </Badge>
415
+ )}
416
+ </SidebarMenuBadge>
417
+ {/* Dot indicator — visible only when sidebar is collapsed */}
418
+ <span
419
+ aria-hidden="true"
420
+ className={cn(
421
+ "absolute top-1 right-1 size-2 rounded-full hidden group-data-[collapsible=icon]:block",
422
+ typeof item.badge === "number" ? "bg-red-600"
423
+ : item.badge === "New" ? "bg-brand"
424
+ : item.badge === "Beta" ? "bg-yellow-400"
425
+ : "bg-primary"
426
+ )}
427
+ />
428
+ </>
429
+ )}
430
+ </SidebarMenuItem>
431
+ )
432
+ })}
433
+ </>
434
+ )
435
+ }
436
+
437
+ /** Utilities-style rows (⌘K, Settings, …) — shared by quick actions + bottom group. */
438
+ function SidebarNavSecondaryItems({
439
+ items,
440
+ pathname,
441
+ }: {
442
+ items: NavSecondaryItem[]
443
+ pathname: string
444
+ }) {
445
+ const mod = useModKeyLabel()
446
+ return (
447
+ <>
448
+ {items.map((item) => {
449
+ const pathOnly = navUrlPath(item.url)
450
+ const linkActive =
451
+ !item.opensCommandMenu &&
452
+ Boolean(pathOnly) &&
453
+ pathOnly !== "#" &&
454
+ isNavActive(pathname, item.url)
455
+
456
+ return (
457
+ <SidebarMenuItem key={item.key}>
458
+ {item.opensCommandMenu ? (
459
+ <SidebarMenuButton
460
+ type="button"
461
+ tooltip={item.title}
462
+ isActive={false}
463
+ onClick={() => requestOpenCommandMenu()}
464
+ >
465
+ <span className="size-4 shrink-0 flex items-center justify-center" aria-hidden="true">
466
+ {item.icon}
467
+ </span>
468
+ <span>{item.title}</span>
469
+ <KbdGroup className="ms-auto">
470
+ <Kbd>{mod}</Kbd>
471
+ <Kbd>K</Kbd>
472
+ </KbdGroup>
473
+ </SidebarMenuButton>
474
+ ) : (
475
+ <SidebarMenuButton asChild isActive={linkActive} tooltip={item.title}>
476
+ <Link href={item.url} aria-current={linkActive ? "page" : undefined}>
477
+ <span
478
+ key={linkActive ? "active" : "idle"}
479
+ className={cn(
480
+ "size-4 shrink-0 flex items-center justify-center",
481
+ linkActive &&
482
+ "[animation:sidebar-icon-pop_380ms_cubic-bezier(0.34,1.56,0.64,1)_both]",
483
+ )}
484
+ aria-hidden="true"
485
+ >
486
+ {linkActive && item.iconActive ? item.iconActive : item.icon}
487
+ </span>
488
+ <span>{item.title}</span>
489
+ </Link>
490
+ </SidebarMenuButton>
491
+ )}
492
+ </SidebarMenuItem>
493
+ )
494
+ })}
495
+ </>
496
+ )
497
+ }
498
+
499
+ // ─────────────────────────────────────────────────────────────────────────────
500
+ // TeamSwitcher — school + program picker in the sidebar header
501
+ // ─────────────────────────────────────────────────────────────────────────────
502
+
503
+ function TeamSwitcher() {
504
+ const { state, isMobile } = useSidebar()
505
+ const [school, setSchool] = React.useState<NavSchool>(NAV_SCHOOL_DEFAULT)
506
+ const [program, setProgram] = React.useState<NavProgram>(NAV_PROGRAM_DEFAULT)
507
+ const [subView, setSubView] = React.useState<"main" | "schools">("main")
508
+
509
+ function switchSchool(s: NavSchool) {
510
+ setSchool(s)
511
+ setProgram(s.programs[0])
512
+ setSubView("main")
513
+ }
514
+
515
+ return (
516
+ <SidebarMenu>
517
+ <SidebarMenuItem>
518
+ <DropdownMenu onOpenChange={(open) => { if (!open) setSubView("main") }}>
519
+ <Tooltip>
520
+ <TooltipTrigger asChild>
521
+ <DropdownMenuTrigger asChild>
522
+ <SidebarMenuButton
523
+ size="lg"
524
+ aria-label={`${school.name} · ${program.name}. Switch school or program`}
525
+ className={cn(
526
+ "py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
527
+ /* `size=lg` is `h-12` + `overflow-hidden` — two lines + avatar need more height */
528
+ (state === "expanded" || isMobile) &&
529
+ "h-auto min-h-12 !overflow-visible items-center [&>span:last-child]:!overflow-visible [&>span:last-child]:!whitespace-normal [&>span:last-child]:text-clip",
530
+ "group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center",
531
+ /* Icon rail: default is `size-8` + `p-2` (~16px inner) — clips 32px avatars; center logo without chevron */
532
+ "group-data-[collapsible=icon]:!size-9 group-data-[collapsible=icon]:!min-h-9 group-data-[collapsible=icon]:!max-h-9 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:overflow-visible",
533
+ )}
534
+ >
535
+ <Avatar
536
+ className={cn(
537
+ "h-8 w-8 shrink-0",
538
+ /* Icon rail: same 36px frame as product mark + header button */
539
+ "group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8",
540
+ )}
541
+ >
542
+ <AvatarImage
543
+ src={school.logo}
544
+ alt=""
545
+ referrerPolicy="origin"
546
+ className="object-contain p-1 group-data-[collapsible=icon]:p-0.5"
547
+ />
548
+ <AvatarFallback className="text-xs font-bold bg-secondary text-secondary-foreground">
549
+ {school.initials}
550
+ </AvatarFallback>
551
+ </Avatar>
552
+ <div
553
+ className={cn(
554
+ "grid min-w-0 flex-1 content-center text-start text-sm leading-snug",
555
+ "group-data-[collapsible=icon]:hidden",
556
+ )}
557
+ >
558
+ <span className="break-words font-medium whitespace-normal">{program.name}</span>
559
+ <span className="break-words text-xs text-muted-foreground whitespace-normal">
560
+ {school.name}
561
+ </span>
562
+ </div>
563
+ {(state === "expanded" || isMobile) && (
564
+ <span
565
+ className="ms-auto flex w-6 shrink-0 self-stretch items-center justify-center text-muted-foreground"
566
+ aria-hidden="true"
567
+ >
568
+ <i className="fa-light fa-chevron-down block text-xs leading-none" aria-hidden="true" />
569
+ </span>
570
+ )}
571
+ </SidebarMenuButton>
572
+ </DropdownMenuTrigger>
573
+ </TooltipTrigger>
574
+ <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile}>
575
+ {program.name} · {school.name}
576
+ </TooltipContent>
577
+ </Tooltip>
578
+
579
+ <DropdownMenuContent
580
+ className="!w-max min-w-72 max-w-[min(100vw-2rem,28rem)]"
581
+ align="start"
582
+ side="right"
583
+ sideOffset={8}
584
+ >
585
+ {subView === "main" ? (
586
+ <>
587
+ {/* Selected school — click to switch school */}
588
+ <div className="p-1">
589
+ <button
590
+ type="button"
591
+ onClick={() => setSubView("schools")}
592
+ className={cn(
593
+ "flex w-full items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors",
594
+ "hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
595
+ )}
596
+ >
597
+ <Avatar className="h-9 w-9 shrink-0">
598
+ <AvatarImage
599
+ src={school.logo}
600
+ alt=""
601
+ referrerPolicy="origin"
602
+ className="object-contain p-0.5"
603
+ />
604
+ <AvatarFallback className="text-xs font-semibold">
605
+ {school.initials}
606
+ </AvatarFallback>
607
+ </Avatar>
608
+ <div className="min-w-0 flex-1">
609
+ <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground leading-tight">
610
+ Program
611
+ </p>
612
+ <p className="mt-0.5 text-[13px] font-semibold leading-snug">
613
+ {program.name}
614
+ </p>
615
+ <p className="mt-0.5 text-xs leading-snug text-muted-foreground">{school.name}</p>
616
+ </div>
617
+ <span className="shrink-0 pt-0.5 text-xs font-medium text-brand">Change</span>
618
+ </button>
619
+ </div>
620
+
621
+ <DropdownMenuSeparator />
622
+
623
+ {/* Programs */}
624
+ <DropdownMenuLabel className="text-xs text-muted-foreground">Program</DropdownMenuLabel>
625
+ {school.programs.map(p => (
626
+ <DropdownMenuItem
627
+ key={p.id}
628
+ onClick={() => setProgram(p)}
629
+ className="items-start py-2"
630
+ >
631
+ <i className="fa-light fa-graduation-cap mt-0.5 shrink-0 text-[13px]" aria-hidden="true" />
632
+ <span className="min-w-0 flex-1 break-words whitespace-normal">{p.name}</span>
633
+ {p.id === program.id && (
634
+ <i className="fa-solid fa-check ms-1 shrink-0 text-brand text-xs mt-0.5" aria-hidden="true" />
635
+ )}
636
+ </DropdownMenuItem>
637
+ ))}
638
+ </>
639
+ ) : (
640
+ <>
641
+ {/* Back + school list */}
642
+ <DropdownMenuItem onSelect={(e) => { e.preventDefault(); setSubView("main") }}>
643
+ <i className="fa-light fa-arrow-left text-[13px]" aria-hidden="true" />
644
+ <span>Back</span>
645
+ </DropdownMenuItem>
646
+ <DropdownMenuSeparator />
647
+ <DropdownMenuLabel className="text-xs text-muted-foreground">Select school</DropdownMenuLabel>
648
+ {NAV_SCHOOLS.map(s => (
649
+ <DropdownMenuItem
650
+ key={s.id}
651
+ onClick={() => switchSchool(s)}
652
+ className="items-start py-2"
653
+ >
654
+ <Avatar size="sm" className="mt-0.5 shrink-0">
655
+ <AvatarImage src={s.logo} alt="" referrerPolicy="origin" />
656
+ <AvatarFallback className="text-xs font-semibold">
657
+ {s.initials}
658
+ </AvatarFallback>
659
+ </Avatar>
660
+ <span className="min-w-0 flex-1 break-words whitespace-normal">{s.name}</span>
661
+ {s.id === school.id && (
662
+ <i className="fa-solid fa-check ms-1 shrink-0 text-brand text-xs mt-0.5" aria-hidden="true" />
663
+ )}
664
+ </DropdownMenuItem>
665
+ ))}
666
+ </>
667
+ )}
668
+ </DropdownMenuContent>
669
+ </DropdownMenu>
670
+ </SidebarMenuItem>
671
+ </SidebarMenu>
672
+ )
673
+ }
674
+
675
+ // ─────────────────────────────────────────────────────────────────────────────
676
+ // Product logo (header) — expanded: full `ExxatProductLogo` + chevron; collapsed: `ExxatProductMark`
677
+ // only (32×32 like school Avatar), no chevron.
678
+ // ─────────────────────────────────────────────────────────────────────────────
679
+
680
+ const PRODUCTS: { id: Product; label: string }[] = [
681
+ { id: "exxat-one", label: "Exxat One" },
682
+ { id: "exxat-prism", label: "Exxat Prism" },
683
+ ]
684
+
685
+ function ProductLogoButton() {
686
+ const { product, setProduct } = useProduct()
687
+ const { state, isMobile } = useSidebar()
688
+ const current = PRODUCTS.find(p => p.id === product) ?? PRODUCTS[0]
689
+ const iconRail = state === "collapsed" && !isMobile
690
+ const expandedOrMobile = state === "expanded" || isMobile
691
+
692
+ return (
693
+ <DropdownMenu>
694
+ <Tooltip>
695
+ <TooltipTrigger asChild>
696
+ <DropdownMenuTrigger asChild>
697
+ <SidebarMenuButton
698
+ size="lg"
699
+ className={cn(
700
+ "py-2 text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground",
701
+ expandedOrMobile &&
702
+ "h-auto min-h-12 !overflow-visible items-center [&>span:last-child]:!overflow-visible [&>span:last-child]:!whitespace-normal [&>span:last-child]:text-clip",
703
+ "group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:justify-center",
704
+ iconRail &&
705
+ "group-data-[collapsible=icon]:!size-9 group-data-[collapsible=icon]:!min-h-9 group-data-[collapsible=icon]:!max-h-9 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:overflow-visible",
706
+ )}
707
+ aria-label={`Current product: ${current.label}. Switch product`}
708
+ suppressHydrationWarning
709
+ >
710
+ {iconRail ? (
711
+ <span className="flex size-8 shrink-0 items-center justify-center">
712
+ <ExxatProductMark
713
+ product={current.id}
714
+ className="size-7 max-h-none"
715
+ />
716
+ </span>
717
+ ) : (
718
+ <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
719
+ <span
720
+ className="flex min-h-0 min-w-0 flex-1 items-center justify-start overflow-visible"
721
+ aria-hidden="true"
722
+ >
723
+ <ExxatProductLogo
724
+ product={current.id}
725
+ variant="mutedSuffix"
726
+ className="h-7 w-auto max-w-[min(100%,260px)] object-left object-contain"
727
+ />
728
+ </span>
729
+ <span
730
+ className="flex w-6 shrink-0 items-center justify-center self-stretch text-muted-foreground"
731
+ aria-hidden="true"
732
+ >
733
+ <i
734
+ className="fa-light fa-chevron-down block text-xs leading-none"
735
+ aria-hidden="true"
736
+ />
737
+ </span>
738
+ </span>
739
+ )}
740
+ </SidebarMenuButton>
741
+ </DropdownMenuTrigger>
742
+ </TooltipTrigger>
743
+ <TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile}>
744
+ {current.label}
745
+ </TooltipContent>
746
+ </Tooltip>
747
+
748
+ <DropdownMenuContent className="w-52" align="start" side="right" sideOffset={8}>
749
+ <DropdownMenuLabel className="text-xs text-muted-foreground">
750
+ Switch product
751
+ </DropdownMenuLabel>
752
+ <DropdownMenuSeparator />
753
+ {PRODUCTS.map(p => (
754
+ <DropdownMenuItem
755
+ key={p.id}
756
+ onClick={() => setProduct(p.id)}
757
+ className="gap-2 py-2"
758
+ aria-selected={p.id === product}
759
+ >
760
+ <ExxatProductLogo
761
+ product={p.id}
762
+ variant="mutedSuffix"
763
+ className="h-7 w-auto shrink-0 max-w-[min(100%,200px)]"
764
+ />
765
+ {p.id === product && (
766
+ <i className="fa-solid fa-check ml-auto text-brand text-xs" aria-hidden="true" />
767
+ )}
768
+ </DropdownMenuItem>
769
+ ))}
770
+ </DropdownMenuContent>
771
+ </DropdownMenu>
772
+ )
773
+ }
774
+
775
+ // ─────────────────────────────────────────────────────────────────────────────
776
+ // AppSidebar
777
+ // ─────────────────────────────────────────────────────────────────────────────
778
+
779
+ /** Light header entrance — Motion (Animate UI–style open distribution: animate-ui.com/docs). */
780
+ function SidebarHeaderStack({ children }: { children: React.ReactNode }) {
781
+ const reduceMotion = useReducedMotion()
782
+ return (
783
+ <motion.div
784
+ className="flex flex-col"
785
+ initial={reduceMotion ? false : { opacity: 0.88, y: -2 }}
786
+ animate={{ opacity: 1, y: 0 }}
787
+ transition={reduceMotion ? { duration: 0 } : motionHeaderEnter}
788
+ >
789
+ {children}
790
+ </motion.div>
791
+ )
792
+ }
793
+
794
+ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
795
+ const pathname = usePathname()
796
+ const { isMobile, setOpen } = useSidebar()
797
+
798
+ return (
799
+ <Sidebar collapsible="icon" {...props}>
800
+ {/*
801
+ Sticky bottom profile + WCAG 1.4.10 Reflow escape hatch.
802
+
803
+ Normal viewport:
804
+ nav → flex column, fills the rail
805
+ content → flex-1 + overflow-auto (scrolls)
806
+ footer → shrink-0 sibling of content (pinned at the bottom)
807
+
808
+ ≥ 200 % browser zoom (≈ viewport height ≤ 640 CSS px) — WCAG 1.4.10
809
+ Reflow requires that content stay reachable at high zoom; sticky/pinned
810
+ elements can otherwise eat most of the short viewport and trap users.
811
+ At that breakpoint we make the <nav> itself the single scroll surface
812
+ and un-flex the content, so the footer falls into the natural document
813
+ flow (nothing is sticky anymore and everything scrolls together).
814
+ */}
815
+ <nav
816
+ aria-label="Application"
817
+ data-exxat-sidebar="application-nav"
818
+ className={cn(
819
+ "flex min-h-0 flex-1 flex-col",
820
+ "[@media(max-height:640px)]:overflow-y-auto",
821
+ )}
822
+ >
823
+ <SidebarContent
824
+ className={cn(
825
+ "gap-0",
826
+ // At high zoom, the outer <nav> becomes the scroller — un-flex the
827
+ // content region so the footer joins the single scroll flow.
828
+ "[@media(max-height:640px)]:!flex-none [@media(max-height:640px)]:!overflow-visible",
829
+ )}
830
+ >
831
+ <SidebarHeader className="border-b border-sidebar-border pb-2">
832
+ {/* Mobile/zoomed: visible close button — WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value */}
833
+ {isMobile && (
834
+ <div className="flex items-center justify-end px-1 pt-0.5">
835
+ <Tip label="Close navigation" side="bottom">
836
+ <Button
837
+ type="button"
838
+ variant="ghost"
839
+ size="icon-sm"
840
+ aria-label="Close navigation"
841
+ onClick={() => setOpen(false)}
842
+ >
843
+ <i className="fa-light fa-xmark text-sm" aria-hidden="true" />
844
+ </Button>
845
+ </Tip>
846
+ </div>
847
+ )}
848
+ <SidebarHeaderStack>
849
+ <SidebarMenu>
850
+ <SidebarMenuItem>
851
+ <ProductLogoButton />
852
+ </SidebarMenuItem>
853
+ </SidebarMenu>
854
+ <TeamSwitcher />
855
+ </SidebarHeaderStack>
856
+ </SidebarHeader>
857
+
858
+ <SidebarGroup className="py-2" role="group" aria-label="Primary">
859
+ <SidebarGroupContent>
860
+ <SidebarMenu className="gap-0.5">
861
+ <SidebarNavSecondaryItems items={NAV_QUICK_ACTIONS} pathname={pathname} />
862
+ <NavLinkItems items={NAV_PRIMARY} pathname={pathname} />
863
+ </SidebarMenu>
864
+ </SidebarGroupContent>
865
+ </SidebarGroup>
866
+
867
+ <SidebarGroup
868
+ className="py-0 pt-0"
869
+ role="group"
870
+ aria-label={NAV_DOCUMENTS_LABEL}
871
+ >
872
+ <SidebarGroupLabel
873
+ id="sidebar-documents-heading"
874
+ className="text-xs font-medium uppercase tracking-wide px-2 text-sidebar-section-label"
875
+ >
876
+ {NAV_DOCUMENTS_LABEL}
877
+ </SidebarGroupLabel>
878
+ <SidebarGroupContent>
879
+ <SidebarMenu className="gap-0.5">
880
+ <NavLinkItems items={NAV_DOCUMENTS} pathname={pathname} />
881
+ </SidebarMenu>
882
+ </SidebarGroupContent>
883
+ </SidebarGroup>
884
+
885
+ <SidebarGroup className="py-2 border-t border-sidebar-border" role="group" aria-label="Utilities">
886
+ <SidebarGroupContent>
887
+ <SidebarMenu className="gap-0.5">
888
+ <SidebarNavSecondaryItems items={NAV_SECONDARY} pathname={pathname} />
889
+ </SidebarMenu>
890
+ </SidebarGroupContent>
891
+ </SidebarGroup>
892
+ </SidebarContent>
893
+
894
+ {/* Sticky profile — sibling of the scroll area, not a child. */}
895
+ <SidebarFooter className="border-t border-sidebar-border">
896
+ <NavUser user={NAV_USER} />
897
+ </SidebarFooter>
898
+ </nav>
899
+ </Sidebar>
900
+ )
901
+ }