@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.
- package/bin/init.mjs +29 -0
- package/package.json +7 -2
- package/template/.nvmrc +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +11 -0
- package/template/AGENTS.md +485 -0
- package/template/Logo/Exxat_Prism.svg +39 -0
- package/template/Logo/Exxat_one.svg +36 -0
- package/template/README.md +58 -0
- package/template/app/(app)/compliance/page.tsx +10 -0
- package/template/app/(app)/dashboard/loading.tsx +18 -0
- package/template/app/(app)/dashboard/page.tsx +36 -0
- package/template/app/(app)/data-list/[id]/page.tsx +28 -0
- package/template/app/(app)/data-list/new/page.tsx +31 -0
- package/template/app/(app)/data-list/page.tsx +10 -0
- package/template/app/(app)/error.tsx +43 -0
- package/template/app/(app)/help/page.tsx +34 -0
- package/template/app/(app)/layout.tsx +54 -0
- package/template/app/(app)/loading.tsx +18 -0
- package/template/app/(app)/question-bank/page.tsx +10 -0
- package/template/app/(app)/rotations/page.tsx +15 -0
- package/template/app/(app)/settings/page.tsx +17 -0
- package/template/app/(app)/sites/all/page.tsx +13 -0
- package/template/app/(app)/team/page.tsx +10 -0
- package/template/app/favicon.ico +0 -0
- package/template/app/globals.css +1811 -0
- package/template/app/layout.tsx +95 -0
- package/template/app/page.tsx +9 -0
- package/template/components/.gitkeep +0 -0
- package/template/components/app-sidebar-dynamic.tsx +15 -0
- package/template/components/app-sidebar.tsx +901 -0
- package/template/components/ask-leo-composer.tsx +216 -0
- package/template/components/ask-leo-sidebar.tsx +509 -0
- package/template/components/chart-area-interactive.tsx +293 -0
- package/template/components/charts-overview.tsx +2321 -0
- package/template/components/command-menu-01.tsx +133 -0
- package/template/components/command-menu-02.tsx +386 -0
- package/template/components/command-menu.tsx +182 -0
- package/template/components/compliance-board-view.tsx +134 -0
- package/template/components/compliance-client.tsx +92 -0
- package/template/components/compliance-list-view.tsx +59 -0
- package/template/components/compliance-page-header.tsx +89 -0
- package/template/components/compliance-table.tsx +525 -0
- package/template/components/dashboard-onboarding-gallery.tsx +13 -0
- package/template/components/dashboard-onboarding.tsx +21 -0
- package/template/components/dashboard-promo-banner.tsx +67 -0
- package/template/components/dashboard-quota-progress-card.tsx +369 -0
- package/template/components/dashboard-report-charts.tsx +69 -0
- package/template/components/dashboard-section-heading.tsx +68 -0
- package/template/components/dashboard-tabs.tsx +598 -0
- package/template/components/data-list-client.tsx +239 -0
- package/template/components/data-list-table-cells.test.tsx +22 -0
- package/template/components/data-list-table-cells.tsx +173 -0
- package/template/components/data-list-table.tsx +879 -0
- package/template/components/data-table/filter-date-calendar.tsx +38 -0
- package/template/components/data-table/filter-text-value-input.tsx +77 -0
- package/template/components/data-table/index.tsx +1612 -0
- package/template/components/data-table/pagination.tsx +256 -0
- package/template/components/data-table/types.ts +91 -0
- package/template/components/data-table/use-table-state.ts +566 -0
- package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
- package/template/components/data-view-dashboard-charts-team.tsx +968 -0
- package/template/components/data-view-dashboard-charts.tsx +1668 -0
- package/template/components/data-views/board-card-primitives.tsx +93 -0
- package/template/components/data-views/index.ts +41 -0
- package/template/components/data-views/list-page-board-card.tsx +192 -0
- package/template/components/data-views/list-page-board-template.tsx +122 -0
- package/template/components/data-views/placement-board-card.tsx +262 -0
- package/template/components/export-drawer.tsx +375 -0
- package/template/components/exxat-product-logo.tsx +453 -0
- package/template/components/form-layout-01.tsx +131 -0
- package/template/components/getting-started.tsx +625 -0
- package/template/components/key-metrics.tsx +920 -0
- package/template/components/leo-insight-indicator.tsx +364 -0
- package/template/components/leo-typing-dots.tsx +121 -0
- package/template/components/list-hub-status-badge.tsx +51 -0
- package/template/components/list-page-dashboard-charts.tsx +18 -0
- package/template/components/nav-documents.tsx +89 -0
- package/template/components/nav-main.tsx +58 -0
- package/template/components/nav-secondary.tsx +64 -0
- package/template/components/nav-user.tsx +190 -0
- package/template/components/new-placement-back-btn.tsx +28 -0
- package/template/components/new-placement-form.tsx +1066 -0
- package/template/components/onboarding/index.ts +4 -0
- package/template/components/onboarding/onboarding-01.tsx +7 -0
- package/template/components/onboarding/onboarding-02.tsx +7 -0
- package/template/components/onboarding/onboarding-03.tsx +7 -0
- package/template/components/onboarding/onboarding-04.tsx +7 -0
- package/template/components/page-header.tsx +57 -0
- package/template/components/placement-detail.tsx +438 -0
- package/template/components/placements-board-view.tsx +404 -0
- package/template/components/placements-list-view.tsx +285 -0
- package/template/components/placements-page-header.tsx +160 -0
- package/template/components/placements-table-columns.tsx +639 -0
- package/template/components/product-switcher.tsx +116 -0
- package/template/components/question-bank-board-view.tsx +205 -0
- package/template/components/question-bank-client.tsx +77 -0
- package/template/components/question-bank-list-view.tsx +59 -0
- package/template/components/question-bank-page-header.tsx +89 -0
- package/template/components/question-bank-table.tsx +586 -0
- package/template/components/rotations-empty-state.tsx +47 -0
- package/template/components/rotations-panel-activator.tsx +8 -0
- package/template/components/secondary-nav.tsx +394 -0
- package/template/components/secondary-panel.tsx +239 -0
- package/template/components/section-cards.tsx +106 -0
- package/template/components/settings-appearance-card.tsx +424 -0
- package/template/components/settings-client.tsx +537 -0
- package/template/components/settings-form-row.tsx +42 -0
- package/template/components/sidebar-auto-collapse.tsx +23 -0
- package/template/components/sidebar-auto-open.tsx +18 -0
- package/template/components/sidebar-shell.tsx +37 -0
- package/template/components/site-header.tsx +93 -0
- package/template/components/sites-all-client.tsx +154 -0
- package/template/components/sites-board-view.tsx +67 -0
- package/template/components/sites-list-view.tsx +47 -0
- package/template/components/sites-table.tsx +312 -0
- package/template/components/system-banner-slot.tsx +66 -0
- package/template/components/table-properties/column-row.tsx +90 -0
- package/template/components/table-properties/draggable-list.ts +49 -0
- package/template/components/table-properties/drawer-button.tsx +231 -0
- package/template/components/table-properties/drawer.tsx +1102 -0
- package/template/components/table-properties/filter-card.tsx +251 -0
- package/template/components/table-properties/index.ts +22 -0
- package/template/components/table-properties/sort-card.tsx +59 -0
- package/template/components/table-properties/types.ts +124 -0
- package/template/components/task-list-panel.tsx +98 -0
- package/template/components/task-priority-badge.tsx +28 -0
- package/template/components/team-board-view.tsx +114 -0
- package/template/components/team-client.tsx +93 -0
- package/template/components/team-list-view.tsx +62 -0
- package/template/components/team-page-header.tsx +92 -0
- package/template/components/team-table.tsx +525 -0
- package/template/components/templates/list-page.tsx +576 -0
- package/template/components/templates/primary-page-template.tsx +56 -0
- package/template/components/theme-color-sync.tsx +32 -0
- package/template/components/theme-provider.tsx +71 -0
- package/template/components/tinted-icon-disc.tsx +53 -0
- package/template/components/ui/ai-thinking-surface.tsx +121 -0
- package/template/components/ui/avatar.tsx +1 -0
- package/template/components/ui/badge.tsx +1 -0
- package/template/components/ui/banner.tsx +1 -0
- package/template/components/ui/breadcrumb.tsx +1 -0
- package/template/components/ui/button.tsx +1 -0
- package/template/components/ui/calendar.tsx +1 -0
- package/template/components/ui/card.tsx +1 -0
- package/template/components/ui/chart.tsx +1 -0
- package/template/components/ui/checkbox.tsx +1 -0
- package/template/components/ui/coach-mark.tsx +1 -0
- package/template/components/ui/collapsible.tsx +1 -0
- package/template/components/ui/command.tsx +1 -0
- package/template/components/ui/date-picker-field.tsx +1 -0
- package/template/components/ui/dialog.tsx +1 -0
- package/template/components/ui/dot-pattern.tsx +159 -0
- package/template/components/ui/drag-handle-grip.tsx +1 -0
- package/template/components/ui/drawer.tsx +1 -0
- package/template/components/ui/dropdown-menu.tsx +1 -0
- package/template/components/ui/field.tsx +1 -0
- package/template/components/ui/form.tsx +1 -0
- package/template/components/ui/input-group.tsx +1 -0
- package/template/components/ui/input-mask.tsx +1 -0
- package/template/components/ui/input.tsx +1 -0
- package/template/components/ui/kbd.tsx +1 -0
- package/template/components/ui/label.tsx +1 -0
- package/template/components/ui/leo-icon.tsx +726 -0
- package/template/components/ui/payment-card-fields.tsx +1 -0
- package/template/components/ui/popover.tsx +1 -0
- package/template/components/ui/radio-group.tsx +1 -0
- package/template/components/ui/select.tsx +1 -0
- package/template/components/ui/selection-tile-grid.tsx +1 -0
- package/template/components/ui/separator.tsx +1 -0
- package/template/components/ui/sheet.tsx +1 -0
- package/template/components/ui/sidebar.tsx +1 -0
- package/template/components/ui/skeleton.tsx +1 -0
- package/template/components/ui/sonner.tsx +1 -0
- package/template/components/ui/status-badge.tsx +1 -0
- package/template/components/ui/table.tsx +1 -0
- package/template/components/ui/tabs.tsx +1 -0
- package/template/components/ui/textarea.tsx +1 -0
- package/template/components/ui/tip.tsx +1 -0
- package/template/components/ui/toggle-group.tsx +1 -0
- package/template/components/ui/toggle-switch.tsx +1 -0
- package/template/components/ui/toggle.tsx +1 -0
- package/template/components/ui/tooltip.tsx +1 -0
- package/template/components/ui/view-segmented-control.tsx +1 -0
- package/template/components.json +27 -0
- package/template/contexts/chart-variant-context.tsx +35 -0
- package/template/contexts/command-menu-context.tsx +28 -0
- package/template/contexts/dashboard-view-context.tsx +35 -0
- package/template/contexts/product-context.tsx +38 -0
- package/template/contexts/system-banner-context.tsx +127 -0
- package/template/docs/command-menu-pattern.md +45 -0
- package/template/docs/data-views-pattern.md +160 -0
- package/template/ecosystem.config.cjs +20 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fontawesome-subset.manifest.json +190 -0
- package/template/hooks/.gitkeep +0 -0
- package/template/hooks/use-app-theme.ts +1 -0
- package/template/hooks/use-coach-mark.ts +1 -0
- package/template/hooks/use-mobile.ts +1 -0
- package/template/hooks/use-mod-key-label.ts +1 -0
- package/template/lib/.gitkeep +0 -0
- package/template/lib/ask-leo-route-context.ts +133 -0
- package/template/lib/chart-keyboard-selection.test.ts +20 -0
- package/template/lib/chart-keyboard-selection.ts +17 -0
- package/template/lib/chart-line-dash.ts +16 -0
- package/template/lib/coach-mark-registry.ts +68 -0
- package/template/lib/command-menu-config.ts +127 -0
- package/template/lib/command-menu-search-data.ts +44 -0
- package/template/lib/conditional-rule-match.ts +32 -0
- package/template/lib/dashboard-customize-coach-mark.ts +18 -0
- package/template/lib/dashboard-layout-merge.ts +63 -0
- package/template/lib/data-list-display-options.ts +35 -0
- package/template/lib/data-list-persistence.ts +280 -0
- package/template/lib/data-list-view-surface.ts +58 -0
- package/template/lib/data-list-view.ts +29 -0
- package/template/lib/data-view-dashboard-storage.ts +101 -0
- package/template/lib/date-filter.ts +8 -0
- package/template/lib/dev-log.test.ts +28 -0
- package/template/lib/dev-log.ts +8 -0
- package/template/lib/editable-target.ts +10 -0
- package/template/lib/floating-sheet-panel.ts +72 -0
- package/template/lib/initials-from-name.ts +7 -0
- package/template/lib/list-page-table-properties.ts +52 -0
- package/template/lib/list-status-badges.ts +168 -0
- package/template/lib/logo-dev.ts +12 -0
- package/template/lib/mock/compliance-kpi.ts +61 -0
- package/template/lib/mock/compliance.ts +146 -0
- package/template/lib/mock/dashboard.ts +105 -0
- package/template/lib/mock/navigation.tsx +231 -0
- package/template/lib/mock/placements-kpi.ts +134 -0
- package/template/lib/mock/placements.ts +183 -0
- package/template/lib/mock/question-bank-kpi.ts +61 -0
- package/template/lib/mock/question-bank.ts +142 -0
- package/template/lib/mock/sites-directory.ts +16 -0
- package/template/lib/mock/sites-kpi.ts +25 -0
- package/template/lib/mock/team-kpi.ts +60 -0
- package/template/lib/mock/team.ts +118 -0
- package/template/lib/motion-ui.ts +17 -0
- package/template/lib/placement-board-card-layout.ts +79 -0
- package/template/lib/placement-lifecycle.ts +5 -0
- package/template/lib/row-height.ts +10 -0
- package/template/lib/stock-portrait.ts +11 -0
- package/template/lib/utils.test.ts +13 -0
- package/template/lib/utils.ts +1 -0
- package/template/next.config.mjs +15 -0
- package/template/package.json +83 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/public/Illustration/Rotation.svg +74 -0
- package/template/public/avatars/user.svg +11 -0
- package/template/public/favicon/favicon.ico +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logos/exxat-one.svg +36 -0
- package/template/public/logos/exxat-prism.svg +39 -0
- package/template/public/mock-schools/emory.svg +4 -0
- package/template/public/mock-schools/rush.svg +4 -0
- package/template/scripts/fontawesome-subset-audit.mjs +190 -0
- package/template/scripts/pm2-startup-macos.sh +13 -0
- package/template/skills-lock.json +10 -0
- package/template/stores/app-store.ts +33 -0
- package/template/tests/setup.ts +1 -0
- package/template/tsconfig.json +35 -0
- package/template/types/react-payment-inputs.d.ts +19 -0
- 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
|
+
}
|