@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,394 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecondaryNav — two-panel contextual navigation
|
|
5
|
+
*
|
|
6
|
+
* Layout:
|
|
7
|
+
* ┌──────┬──────────────────────────┐
|
|
8
|
+
* │ Rail │ Content panel │
|
|
9
|
+
* │ (52) │ (200–240px) │
|
|
10
|
+
* │ │ Section heading │
|
|
11
|
+
* │ ◉ │ · Nav item │
|
|
12
|
+
* │ ○ │ · Nav item (active) │
|
|
13
|
+
* │ ○ │ ───────────────── │
|
|
14
|
+
* │ │ Section heading │
|
|
15
|
+
* │ │ · Nav item │
|
|
16
|
+
* └──────┴──────────────────────────┘
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* <SecondaryNav sections={SECTIONS} />
|
|
20
|
+
*
|
|
21
|
+
* Or use composed pieces:
|
|
22
|
+
* <SecondaryNavRail sections={…} activeSection={…} onSectionChange={…} />
|
|
23
|
+
* <SecondaryNavPanel section={…} />
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as React from "react"
|
|
27
|
+
import { usePathname } from "next/navigation"
|
|
28
|
+
import { cn } from "@/lib/utils"
|
|
29
|
+
import {
|
|
30
|
+
Tooltip,
|
|
31
|
+
TooltipContent,
|
|
32
|
+
TooltipProvider,
|
|
33
|
+
TooltipTrigger,
|
|
34
|
+
} from "@/components/ui/tooltip"
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Types
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface SecondaryNavLink {
|
|
41
|
+
/** Unique key */
|
|
42
|
+
key: string
|
|
43
|
+
label: string
|
|
44
|
+
href: string
|
|
45
|
+
icon?: string
|
|
46
|
+
/** Badge count shown on link */
|
|
47
|
+
count?: number
|
|
48
|
+
/** If true, item is rendered as a section divider label (not a link) */
|
|
49
|
+
isSectionHeader?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SecondaryNavSectionAction {
|
|
53
|
+
/** Tooltip / aria-label */
|
|
54
|
+
label: string
|
|
55
|
+
/** FontAwesome icon class, e.g. "fa-plus" */
|
|
56
|
+
icon: string
|
|
57
|
+
onClick: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SecondaryNavSection {
|
|
61
|
+
/** Unique key — used to identify the active section */
|
|
62
|
+
key: string
|
|
63
|
+
/** Tooltip shown on rail icon */
|
|
64
|
+
label: string
|
|
65
|
+
/** FontAwesome icon class, e.g. "fa-users" */
|
|
66
|
+
icon: string
|
|
67
|
+
/** Solid icon used when section is active */
|
|
68
|
+
iconActive?: string
|
|
69
|
+
/** Flat list of links (use isSectionHeader=true for dividers) */
|
|
70
|
+
links: SecondaryNavLink[]
|
|
71
|
+
/** When true, a search input is shown above the list and filters link labels */
|
|
72
|
+
searchable?: boolean
|
|
73
|
+
/** Placeholder for the search input */
|
|
74
|
+
searchPlaceholder?: string
|
|
75
|
+
/** Optional primary action rendered next to the section title (e.g. Add) */
|
|
76
|
+
action?: SecondaryNavSectionAction
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
// RailButton — single icon in the narrow left rail
|
|
81
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
function RailButton({
|
|
84
|
+
section,
|
|
85
|
+
isActive,
|
|
86
|
+
onClick,
|
|
87
|
+
}: {
|
|
88
|
+
section: SecondaryNavSection
|
|
89
|
+
isActive: boolean
|
|
90
|
+
onClick: () => void
|
|
91
|
+
}) {
|
|
92
|
+
return (
|
|
93
|
+
<Tooltip>
|
|
94
|
+
<TooltipTrigger asChild>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-label={section.label}
|
|
98
|
+
aria-current={isActive ? "true" : undefined}
|
|
99
|
+
onClick={onClick}
|
|
100
|
+
className={cn(
|
|
101
|
+
"relative flex items-center justify-center size-9 rounded-lg transition-all",
|
|
102
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
|
|
103
|
+
isActive
|
|
104
|
+
? [
|
|
105
|
+
"text-[var(--brand-color)]",
|
|
106
|
+
"bg-[color-mix(in_oklch,var(--background)_88%,var(--brand-color)_12%)]",
|
|
107
|
+
].join(" ")
|
|
108
|
+
: "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-strong"
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{/* Active left pip */}
|
|
112
|
+
{isActive && (
|
|
113
|
+
<span
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full bg-[var(--brand-color)]"
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
<i
|
|
119
|
+
className={cn(
|
|
120
|
+
isActive && section.iconActive
|
|
121
|
+
? `fa-solid ${section.iconActive}`
|
|
122
|
+
: `fa-light ${section.icon}`,
|
|
123
|
+
"text-[15px]"
|
|
124
|
+
)}
|
|
125
|
+
aria-hidden="true"
|
|
126
|
+
/>
|
|
127
|
+
</button>
|
|
128
|
+
</TooltipTrigger>
|
|
129
|
+
<TooltipContent side="right" sideOffset={6}>
|
|
130
|
+
{section.label}
|
|
131
|
+
</TooltipContent>
|
|
132
|
+
</Tooltip>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
// NavLink — single item in the content panel
|
|
138
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function NavLink({ link }: { link: SecondaryNavLink }) {
|
|
141
|
+
const pathname = usePathname()
|
|
142
|
+
const isActive = pathname === link.href || pathname.startsWith(link.href + "/")
|
|
143
|
+
|
|
144
|
+
if (link.isSectionHeader) {
|
|
145
|
+
return (
|
|
146
|
+
<li role="presentation">
|
|
147
|
+
<span className="block px-3 pt-3 pb-0.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 select-none">
|
|
148
|
+
{link.label}
|
|
149
|
+
</span>
|
|
150
|
+
</li>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<li>
|
|
156
|
+
<a
|
|
157
|
+
href={link.href}
|
|
158
|
+
aria-current={isActive ? "page" : undefined}
|
|
159
|
+
className={cn(
|
|
160
|
+
"group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-sm transition-colors",
|
|
161
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
162
|
+
isActive
|
|
163
|
+
? [
|
|
164
|
+
"font-medium text-[var(--brand-color)]",
|
|
165
|
+
"bg-[color-mix(in_oklch,var(--background)_88%,var(--brand-color)_12%)]",
|
|
166
|
+
].join(" ")
|
|
167
|
+
: "text-foreground/80 hover:text-interactive-hover-foreground hover:bg-interactive-hover-medium"
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{link.icon && (
|
|
171
|
+
<span className="size-4 shrink-0 flex items-center justify-center">
|
|
172
|
+
<i
|
|
173
|
+
className={cn(
|
|
174
|
+
isActive ? `fa-solid ${link.icon}` : `fa-light ${link.icon}`,
|
|
175
|
+
"text-[13px]"
|
|
176
|
+
)}
|
|
177
|
+
aria-hidden="true"
|
|
178
|
+
/>
|
|
179
|
+
</span>
|
|
180
|
+
)}
|
|
181
|
+
<span className="flex-1 min-w-0 truncate">{link.label}</span>
|
|
182
|
+
{link.count !== undefined && (
|
|
183
|
+
<span
|
|
184
|
+
className={cn(
|
|
185
|
+
"shrink-0 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-xs font-medium px-1",
|
|
186
|
+
isActive
|
|
187
|
+
? "bg-[var(--brand-color)] text-white"
|
|
188
|
+
: "bg-muted text-muted-foreground"
|
|
189
|
+
)}
|
|
190
|
+
>
|
|
191
|
+
{link.count}
|
|
192
|
+
</span>
|
|
193
|
+
)}
|
|
194
|
+
</a>
|
|
195
|
+
</li>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
// SecondaryNavRail — exported if consumers want to compose manually
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export function SecondaryNavRail({
|
|
204
|
+
sections,
|
|
205
|
+
activeSection,
|
|
206
|
+
onSectionChange,
|
|
207
|
+
className,
|
|
208
|
+
}: {
|
|
209
|
+
sections: SecondaryNavSection[]
|
|
210
|
+
activeSection: string
|
|
211
|
+
onSectionChange: (key: string) => void
|
|
212
|
+
className?: string
|
|
213
|
+
}) {
|
|
214
|
+
return (
|
|
215
|
+
<nav
|
|
216
|
+
aria-label="Section navigation"
|
|
217
|
+
className={cn(
|
|
218
|
+
"flex flex-col items-center gap-1 pt-3 pb-2 px-1.5 w-[52px] shrink-0 border-r border-border",
|
|
219
|
+
className
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
<TooltipProvider delayDuration={200}>
|
|
223
|
+
{sections.map(section => (
|
|
224
|
+
<RailButton
|
|
225
|
+
key={section.key}
|
|
226
|
+
section={section}
|
|
227
|
+
isActive={activeSection === section.key}
|
|
228
|
+
onClick={() => onSectionChange(section.key)}
|
|
229
|
+
/>
|
|
230
|
+
))}
|
|
231
|
+
</TooltipProvider>
|
|
232
|
+
</nav>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
// SecondaryNavPanel — exported for manual composition
|
|
238
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export function SecondaryNavPanel({
|
|
241
|
+
section,
|
|
242
|
+
className,
|
|
243
|
+
}: {
|
|
244
|
+
section: SecondaryNavSection
|
|
245
|
+
className?: string
|
|
246
|
+
}) {
|
|
247
|
+
const [query, setQuery] = React.useState("")
|
|
248
|
+
const q = query.trim().toLowerCase()
|
|
249
|
+
|
|
250
|
+
const visibleLinks = React.useMemo(() => {
|
|
251
|
+
if (!section.searchable || !q) return section.links
|
|
252
|
+
// Filter out non-header items that don't match; drop headers whose group becomes empty
|
|
253
|
+
const kept: SecondaryNavLink[] = []
|
|
254
|
+
let pendingHeader: SecondaryNavLink | null = null
|
|
255
|
+
let groupHasMatch = false
|
|
256
|
+
for (const link of section.links) {
|
|
257
|
+
if (link.isSectionHeader) {
|
|
258
|
+
if (pendingHeader && groupHasMatch) kept.push(pendingHeader)
|
|
259
|
+
pendingHeader = link
|
|
260
|
+
groupHasMatch = false
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (link.label.toLowerCase().includes(q)) {
|
|
264
|
+
if (pendingHeader && !groupHasMatch) {
|
|
265
|
+
kept.push(pendingHeader)
|
|
266
|
+
groupHasMatch = true
|
|
267
|
+
} else if (!pendingHeader) {
|
|
268
|
+
groupHasMatch = true
|
|
269
|
+
}
|
|
270
|
+
kept.push(link)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (pendingHeader && groupHasMatch) {
|
|
274
|
+
// already pushed above when first match in group — nothing to do
|
|
275
|
+
}
|
|
276
|
+
return kept
|
|
277
|
+
}, [section.links, section.searchable, q])
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<nav
|
|
281
|
+
aria-label={`${section.label} navigation`}
|
|
282
|
+
className={cn("flex flex-col min-w-[200px] max-w-[240px] pt-3 pb-2", className)}
|
|
283
|
+
>
|
|
284
|
+
{/* Section title + action */}
|
|
285
|
+
<div className="flex items-center justify-between gap-2 px-3 pb-2">
|
|
286
|
+
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 select-none">
|
|
287
|
+
{section.label}
|
|
288
|
+
</p>
|
|
289
|
+
{section.action && (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
aria-label={section.action.label}
|
|
293
|
+
title={section.action.label}
|
|
294
|
+
onClick={section.action.onClick}
|
|
295
|
+
className={cn(
|
|
296
|
+
"inline-flex size-6 items-center justify-center rounded-md -mr-1",
|
|
297
|
+
"text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-strong",
|
|
298
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
<i className={cn("fa-light", section.action.icon, "text-[13px]")} aria-hidden="true" />
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
{/* Search */}
|
|
307
|
+
{section.searchable && (
|
|
308
|
+
<div className="px-2 pb-2">
|
|
309
|
+
<div className="relative">
|
|
310
|
+
<i
|
|
311
|
+
className="fa-light fa-magnifying-glass absolute left-2 top-1/2 -translate-y-1/2 text-[11px] text-muted-foreground pointer-events-none"
|
|
312
|
+
aria-hidden="true"
|
|
313
|
+
/>
|
|
314
|
+
<input
|
|
315
|
+
type="search"
|
|
316
|
+
value={query}
|
|
317
|
+
onChange={e => setQuery(e.target.value)}
|
|
318
|
+
placeholder={section.searchPlaceholder ?? "Search"}
|
|
319
|
+
aria-label={`Search ${section.label}`}
|
|
320
|
+
className={cn(
|
|
321
|
+
"w-full h-7 pl-7 pr-2 rounded-md text-xs bg-background border border-border",
|
|
322
|
+
"placeholder:text-muted-foreground/70",
|
|
323
|
+
"focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
|
|
324
|
+
)}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
<ul role="list" className="flex flex-col gap-0.5 px-1.5">
|
|
331
|
+
{visibleLinks.map(link => (
|
|
332
|
+
<NavLink key={link.key} link={link} />
|
|
333
|
+
))}
|
|
334
|
+
{section.searchable && q && visibleLinks.length === 0 && (
|
|
335
|
+
<li className="px-3 py-2 text-xs text-muted-foreground">No results</li>
|
|
336
|
+
)}
|
|
337
|
+
</ul>
|
|
338
|
+
</nav>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
343
|
+
// SecondaryNav — composed two-panel component (default export)
|
|
344
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
export interface SecondaryNavProps {
|
|
347
|
+
sections: SecondaryNavSection[]
|
|
348
|
+
/** Which section key is active by default — defaults to first section */
|
|
349
|
+
defaultSection?: string
|
|
350
|
+
className?: string
|
|
351
|
+
/** Called when active section changes */
|
|
352
|
+
onSectionChange?: (key: string) => void
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function SecondaryNav({
|
|
356
|
+
sections,
|
|
357
|
+
defaultSection,
|
|
358
|
+
className,
|
|
359
|
+
onSectionChange,
|
|
360
|
+
}: SecondaryNavProps) {
|
|
361
|
+
const [activeSection, setActiveSection] = React.useState(
|
|
362
|
+
defaultSection ?? sections[0]?.key ?? ""
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
const currentSection = sections.find(s => s.key === activeSection) ?? sections[0]
|
|
366
|
+
|
|
367
|
+
function handleSectionChange(key: string) {
|
|
368
|
+
setActiveSection(key)
|
|
369
|
+
onSectionChange?.(key)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!currentSection) return null
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
className={cn(
|
|
377
|
+
"flex h-full border-r border-border bg-sidebar",
|
|
378
|
+
className
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
{/* Left icon rail — only shown when multiple sections */}
|
|
382
|
+
{sections.length > 1 && (
|
|
383
|
+
<SecondaryNavRail
|
|
384
|
+
sections={sections}
|
|
385
|
+
activeSection={activeSection}
|
|
386
|
+
onSectionChange={handleSectionChange}
|
|
387
|
+
/>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{/* Right content panel */}
|
|
391
|
+
<SecondaryNavPanel section={currentSection} />
|
|
392
|
+
</div>
|
|
393
|
+
)
|
|
394
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecondaryPanel — nested sidebar panel that appears between the icon-rail
|
|
5
|
+
* sidebar and the main content. When active, the main sidebar collapses to
|
|
6
|
+
* icon-only mode; when dismissed, the sidebar expands back.
|
|
7
|
+
*
|
|
8
|
+
* Pattern: VS Code / Figma icon-rail + detail panel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as React from "react"
|
|
12
|
+
import { cn } from "@/lib/utils"
|
|
13
|
+
import { useSidebar } from "@/components/ui/sidebar"
|
|
14
|
+
import { Tip } from "@/components/ui/tip"
|
|
15
|
+
import { Button } from "@/components/ui/button"
|
|
16
|
+
import {
|
|
17
|
+
InputGroup,
|
|
18
|
+
InputGroupAddon,
|
|
19
|
+
InputGroupInput,
|
|
20
|
+
} from "@/components/ui/input-group"
|
|
21
|
+
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// Context
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface SecondaryPanelContextValue {
|
|
27
|
+
/** Currently active panel id, or null if none */
|
|
28
|
+
activePanel: string | null
|
|
29
|
+
/** Open a panel by id */
|
|
30
|
+
openPanel: (id: string) => void
|
|
31
|
+
/** Close the panel */
|
|
32
|
+
closePanel: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SecondaryPanelContext = React.createContext<SecondaryPanelContextValue>({
|
|
36
|
+
activePanel: null,
|
|
37
|
+
openPanel: () => {},
|
|
38
|
+
closePanel: () => {},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export function useSecondaryPanel() {
|
|
42
|
+
return React.useContext(SecondaryPanelContext)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SecondaryPanelProvider({ children }: { children: React.ReactNode }) {
|
|
46
|
+
const [activePanel, setActivePanel] = React.useState<string | null>(null)
|
|
47
|
+
const { setOpen } = useSidebar()
|
|
48
|
+
|
|
49
|
+
const openPanel = React.useCallback((id: string) => {
|
|
50
|
+
setActivePanel(id)
|
|
51
|
+
setOpen(false) // collapse main sidebar to icon rail
|
|
52
|
+
}, [setOpen])
|
|
53
|
+
|
|
54
|
+
const closePanel = React.useCallback(() => {
|
|
55
|
+
setActivePanel(null)
|
|
56
|
+
setOpen(true) // expand main sidebar back
|
|
57
|
+
}, [setOpen])
|
|
58
|
+
|
|
59
|
+
const value = React.useMemo(() => ({
|
|
60
|
+
activePanel, openPanel, closePanel,
|
|
61
|
+
}), [activePanel, openPanel, closePanel])
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<SecondaryPanelContext.Provider value={value}>
|
|
65
|
+
{children}
|
|
66
|
+
</SecondaryPanelContext.Provider>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// Panel content — Rotations
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const ROTATION_ITEMS = [
|
|
75
|
+
{ id: "all", label: "All Rotations", icon: "fa-folder", iconActive: "fa-folder", meta: "12 active" },
|
|
76
|
+
{ id: "rotation-1", label: "Clinical Nursing — Fall 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "8 students" },
|
|
77
|
+
{ id: "rotation-2", label: "PT Fieldwork — Spring 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "6 students" },
|
|
78
|
+
{ id: "rotation-3", label: "OT Level II — Summer 2026", icon: "fa-folder", iconActive: "fa-folder", meta: "4 students" },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
function RotationsPanel() {
|
|
82
|
+
const { closePanel } = useSecondaryPanel()
|
|
83
|
+
const [activeRotation, setActiveRotation] = React.useState("all")
|
|
84
|
+
const [query, setQuery] = React.useState("")
|
|
85
|
+
|
|
86
|
+
const q = query.trim().toLowerCase()
|
|
87
|
+
const filtered = q
|
|
88
|
+
? ROTATION_ITEMS.filter(i => i.label.toLowerCase().includes(q))
|
|
89
|
+
: ROTATION_ITEMS
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<div className="flex items-center justify-between gap-2 px-4 pt-4 pb-2">
|
|
94
|
+
<h2
|
|
95
|
+
className="text-xl font-semibold leading-tight text-sidebar-foreground"
|
|
96
|
+
style={{ fontFamily: "var(--font-heading)" }}
|
|
97
|
+
>
|
|
98
|
+
Rotations
|
|
99
|
+
</h2>
|
|
100
|
+
<div className="flex items-center gap-1">
|
|
101
|
+
<Tip label="Add rotation" side="bottom">
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
size="icon"
|
|
105
|
+
variant="ghost"
|
|
106
|
+
onClick={() => { /* hook up create flow */ }}
|
|
107
|
+
aria-label="Add rotation"
|
|
108
|
+
>
|
|
109
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
110
|
+
</Button>
|
|
111
|
+
</Tip>
|
|
112
|
+
<Tip label="Close panel" side="bottom">
|
|
113
|
+
<Button
|
|
114
|
+
type="button"
|
|
115
|
+
size="icon"
|
|
116
|
+
variant="ghost"
|
|
117
|
+
onClick={closePanel}
|
|
118
|
+
aria-label="Close panel"
|
|
119
|
+
>
|
|
120
|
+
<i className="fa-light fa-xmark" aria-hidden="true" />
|
|
121
|
+
</Button>
|
|
122
|
+
</Tip>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="px-4 pb-2">
|
|
126
|
+
<InputGroup className="h-8 min-h-8">
|
|
127
|
+
<InputGroupAddon>
|
|
128
|
+
<i className="fa-light fa-magnifying-glass size-4 text-[13px]" aria-hidden="true" />
|
|
129
|
+
</InputGroupAddon>
|
|
130
|
+
<InputGroupInput
|
|
131
|
+
type="search"
|
|
132
|
+
value={query}
|
|
133
|
+
onChange={e => setQuery(e.target.value)}
|
|
134
|
+
placeholder="Search rotations"
|
|
135
|
+
aria-label="Search rotations"
|
|
136
|
+
className="text-sm pe-3"
|
|
137
|
+
/>
|
|
138
|
+
</InputGroup>
|
|
139
|
+
</div>
|
|
140
|
+
<ul className="flex-1 space-y-0.5 px-4 pb-4" role="listbox">
|
|
141
|
+
{filtered.length === 0 && (
|
|
142
|
+
<li className="py-2 text-xs text-muted-foreground">No results</li>
|
|
143
|
+
)}
|
|
144
|
+
{filtered.map(item => {
|
|
145
|
+
const isActive = item.id === activeRotation
|
|
146
|
+
return (
|
|
147
|
+
<li key={item.id}>
|
|
148
|
+
<Tip label={item.label} side="right">
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
role="option"
|
|
152
|
+
aria-selected={isActive}
|
|
153
|
+
onClick={() => setActiveRotation(item.id)}
|
|
154
|
+
className={cn(
|
|
155
|
+
// Match primary `SidebarMenuButton`: text-sm, compact padding, sidebar tokens
|
|
156
|
+
"flex w-full items-start gap-2 rounded-md p-2 text-left text-sm transition-colors",
|
|
157
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
158
|
+
isActive
|
|
159
|
+
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
|
160
|
+
: "text-sidebar-foreground hover:bg-sidebar-accent/50",
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
<i
|
|
164
|
+
className={cn(
|
|
165
|
+
"mt-0.5 size-4 shrink-0 text-center text-[13px] leading-none",
|
|
166
|
+
isActive ? `fa-solid ${item.iconActive}` : `fa-light ${item.icon}`,
|
|
167
|
+
)}
|
|
168
|
+
aria-hidden="true"
|
|
169
|
+
/>
|
|
170
|
+
<div className="min-w-0 flex-1">
|
|
171
|
+
<span className="block whitespace-normal break-words leading-snug">
|
|
172
|
+
{item.label}
|
|
173
|
+
</span>
|
|
174
|
+
{item.meta && (
|
|
175
|
+
<span className="mt-0.5 block whitespace-normal break-words text-xs leading-snug text-muted-foreground">
|
|
176
|
+
{item.meta}
|
|
177
|
+
</span>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</button>
|
|
181
|
+
</Tip>
|
|
182
|
+
</li>
|
|
183
|
+
)
|
|
184
|
+
})}
|
|
185
|
+
</ul>
|
|
186
|
+
</>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
// SecondaryPanel — the actual rendered panel
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
const PANELS: Record<string, React.FC> = {
|
|
195
|
+
rotations: RotationsPanel,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function SecondaryPanel() {
|
|
199
|
+
const { activePanel } = useSecondaryPanel()
|
|
200
|
+
const PanelContent = activePanel ? PANELS[activePanel] : null
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<nav
|
|
204
|
+
aria-label="Secondary navigation"
|
|
205
|
+
data-state={activePanel ? "open" : "closed"}
|
|
206
|
+
className={cn(
|
|
207
|
+
"flex flex-col overflow-hidden",
|
|
208
|
+
"transition-[width,margin,opacity] duration-200 ease-linear",
|
|
209
|
+
activePanel
|
|
210
|
+
? "w-56 shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative h-[min(calc(100svh-2rem),800px)] md:sticky md:top-2 md:h-[min(calc(100svh-1rem),800px)]"
|
|
211
|
+
: "h-0 min-h-0 shrink overflow-hidden border-0 p-0 m-0 min-w-0 w-0 max-w-0 opacity-0 pointer-events-none"
|
|
212
|
+
)}
|
|
213
|
+
style={activePanel ? { backgroundColor: "var(--secondary-panel-bg, #FAFBFF)" } : undefined}
|
|
214
|
+
>
|
|
215
|
+
<div
|
|
216
|
+
className={cn(
|
|
217
|
+
"flex flex-1 flex-col overflow-y-auto",
|
|
218
|
+
activePanel ? "min-w-0" : "hidden min-w-0 w-0 p-0"
|
|
219
|
+
)}
|
|
220
|
+
>
|
|
221
|
+
{PanelContent && <PanelContent />}
|
|
222
|
+
</div>
|
|
223
|
+
</nav>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
// Auto-open hook — pages call this to show/hide a panel on mount/unmount
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
export function useAutoPanel(panelId: string) {
|
|
232
|
+
const { openPanel, closePanel } = useSecondaryPanel()
|
|
233
|
+
|
|
234
|
+
React.useEffect(() => {
|
|
235
|
+
openPanel(panelId)
|
|
236
|
+
return () => { closePanel() }
|
|
237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
238
|
+
}, [panelId])
|
|
239
|
+
}
|