diff --git a/apps/apollo-vertex/app/_components/preview-full-screen.tsx b/apps/apollo-vertex/app/_components/preview-full-screen.tsx index a20ea3545..aeea7d612 100644 --- a/apps/apollo-vertex/app/_components/preview-full-screen.tsx +++ b/apps/apollo-vertex/app/_components/preview-full-screen.tsx @@ -31,7 +31,7 @@ export function PreviewFullScreen({ style={{ height }} > {!isOpen && ( -
{children}
+
{children}
)} -
{children}
+
{children}
); diff --git a/apps/apollo-vertex/app/components/sidebar/page.mdx b/apps/apollo-vertex/app/components/sidebar/page.mdx index 542542fa6..97415b083 100644 --- a/apps/apollo-vertex/app/components/sidebar/page.mdx +++ b/apps/apollo-vertex/app/components/sidebar/page.mdx @@ -1,12 +1,13 @@ +import { SidebarExampleTemplate } from '@/templates/SidebarTemplateDynamic'; +import { PreviewFullScreen } from '@/app/_components/preview-full-screen'; + # Sidebar A composable, themeable and customizable sidebar component. -
-

- The Sidebar component is a complex layout component. See the usage example below for implementation details. -

-
+ + + ## Installation @@ -42,19 +43,28 @@ import { ## Features -- **Collapsible** - Can collapse to icons only -- **Composable** - Build your own sidebar structure -- **Themeable** - Supports dark mode and custom themes -- **Responsive** - Works well on mobile and desktop -- **Keyboard accessible** - Full keyboard navigation support +- **Collapsible** - Can collapse to icons only with `collapsible="icon"` +- **Composable** - Build your own sidebar structure with provided primitives +- **Themeable** - Supports dark mode and custom themes via CSS variables +- **Responsive** - Renders as a sheet on mobile, fixed sidebar on desktop +- **Keyboard accessible** - Toggle with `⌘B`, full keyboard navigation support ## Components - `SidebarProvider` - Provides context for sidebar state - `Sidebar` - The main sidebar container - `SidebarHeader` - Header section of the sidebar -- `SidebarContent` - Main content area +- `SidebarContent` - Main scrollable content area - `SidebarFooter` - Footer section - `SidebarGroup` - Groups related items +- `SidebarGroupLabel` - Label for a group +- `SidebarGroupContent` - Content wrapper for a group - `SidebarMenu` - Navigation menu +- `SidebarMenuButton` - Clickable menu button with tooltip support - `SidebarMenuItem` - Individual menu items +- `SidebarMenuSub` - Sub-menu container for nested navigation +- `SidebarMenuSubButton` - Sub-menu item button +- `SidebarMenuSubItem` - Sub-menu item wrapper +- `SidebarInset` - Main content area adjacent to the sidebar +- `SidebarTrigger` - Button to toggle sidebar open/closed +- `SidebarRail` - Narrow rail for hover-to-expand interaction diff --git a/apps/apollo-vertex/app/patterns/shell/page.mdx b/apps/apollo-vertex/app/patterns/shell/page.mdx index 6bdd2e181..73eb2a884 100644 --- a/apps/apollo-vertex/app/patterns/shell/page.mdx +++ b/apps/apollo-vertex/app/patterns/shell/page.mdx @@ -19,6 +19,10 @@ Use the `variant="minimal"` prop to render a horizontal header layout instead of ## Features +- **Collapsible Sidebar**: Icon-only collapsed mode with smooth spring animations, built on the shadcn sidebar primitives +- **Sub-Navigation**: Collapsible menu items with nested sub-items that auto-expand when active +- **Collapsed Menu Handling**: Clicking a collapsible item while collapsed expands the sidebar and opens the submenu +- **Custom Logo**: Support for company logos with separate light/dark mode variants - **Optional OAuth2 Authentication**: Plug-and-play authorization code flow with PKCE - **Theme Toggle**: Built-in light/dark mode support - **Language Toggle**: Built-in language switcher for internationalization @@ -40,7 +44,15 @@ import { ApolloShell } from '@/components/ui/shell'; const navItems = [ { path: '/dashboard', label: 'dashboard', icon: Home }, - { path: '/settings', label: 'settings', icon: Settings }, + { + path: '/settings', + label: 'settings', + icon: Settings, + subItems: [ + { path: '/settings', label: 'settings' }, + { path: '/settings/appearance', label: 'appearance' }, + ], + }, ]; function App() { diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index 715a00026..2fde3b4c8 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -105,6 +105,7 @@ "turkish": "Turkish", "type_a_message": "Type a message...", "user_email_placeholder": "user@company.com", + "user_profile": "Profile and settings", "view": "View", "view_customer": "View customer", "view_payment_details": "View payment details", diff --git a/apps/apollo-vertex/next-env.d.ts b/apps/apollo-vertex/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/apollo-vertex/next-env.d.ts +++ b/apps/apollo-vertex/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/apollo-vertex/public/UiPath.svg b/apps/apollo-vertex/public/UiPath.svg index 92eb9870f..a8622d7f3 100644 --- a/apps/apollo-vertex/public/UiPath.svg +++ b/apps/apollo-vertex/public/UiPath.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/apps/apollo-vertex/public/UiPath_dark.svg b/apps/apollo-vertex/public/UiPath_dark.svg index ac46b3097..a8622d7f3 100644 --- a/apps/apollo-vertex/public/UiPath_dark.svg +++ b/apps/apollo-vertex/public/UiPath_dark.svg @@ -1,13 +1,7 @@ - - - - - - - - - - - - + + + + + + diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 170d79712..50278a7e3 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -506,6 +506,10 @@ { "path": "registry/collapsible/collapsible.tsx", "type": "registry:ui" + }, + { + "path": "registry/collapsible/collapsible.css", + "type": "registry:ui" } ] }, @@ -953,7 +957,6 @@ "description": "Provides UiPath authentication and renders children only when authenticated.", "dependencies": [ "lucide-react", - "framer-motion", "react-i18next", "i18next", "jwt-decode", @@ -962,14 +965,16 @@ "@tanstack/react-router", "pkce-challenge", "sonner", - "@mantine/hooks@^9.0.0" + "framer-motion" ], "registryDependencies": [ "button", "tooltip", "avatar", "dropdown-menu", - "spinner" + "skeleton", + "sidebar", + "collapsible" ], "files": [ { "path": "registry/shell/shell.tsx", "type": "registry:ui" }, @@ -1004,14 +1009,6 @@ { "path": "lib/i18n.ts", "type": "registry:lib" }, { "path": "lib/auth.ts", "type": "registry:lib" }, { "path": "lib/react-i18next.d.ts", "type": "registry:lib" }, - { - "path": "registry/shell/shell-animations.ts", - "type": "registry:ui" - }, - { - "path": "registry/shell/shell-nav-item.tsx", - "type": "registry:ui" - }, { "path": "registry/shell/shell-minimal-company.tsx", "type": "registry:ui" @@ -1024,10 +1021,6 @@ "path": "registry/shell/shell-text.tsx", "type": "registry:ui" }, - { - "path": "registry/shell/shell-theme-toggle.tsx", - "type": "registry:ui" - }, { "path": "registry/shell/shell-theme-provider.tsx", "type": "registry:ui" @@ -1055,6 +1048,10 @@ { "path": "registry/shell/shell-user-profile-menu-items.tsx", "type": "registry:ui" + }, + { + "path": "registry/shell/shell-animations.ts", + "type": "registry:ui" } ] }, @@ -1139,6 +1136,7 @@ "dependencies": [ "@radix-ui/react-slot", "class-variance-authority", + "framer-motion", "lucide-react", "react-i18next" ], diff --git a/apps/apollo-vertex/registry/collapsible/collapsible.css b/apps/apollo-vertex/registry/collapsible/collapsible.css new file mode 100644 index 000000000..690fd229d --- /dev/null +++ b/apps/apollo-vertex/registry/collapsible/collapsible.css @@ -0,0 +1,25 @@ +@keyframes collapsible-down { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height); + } +} + +@keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height); + } + to { + height: 0; + } +} + +@utility animate-collapsible-down { + animation: collapsible-down 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +@utility animate-collapsible-up { + animation: collapsible-up 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/apps/apollo-vertex/registry/collapsible/collapsible.tsx b/apps/apollo-vertex/registry/collapsible/collapsible.tsx index 90935c6b2..d5223a141 100644 --- a/apps/apollo-vertex/registry/collapsible/collapsible.tsx +++ b/apps/apollo-vertex/registry/collapsible/collapsible.tsx @@ -1,6 +1,8 @@ "use client"; import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; +import { cn } from "@/lib/utils"; +import "./collapsible.css"; function Collapsible({ ...props @@ -20,11 +22,16 @@ function CollapsibleTrigger({ } function CollapsibleContent({ + className, ...props }: React.ComponentProps) { return ( ); diff --git a/apps/apollo-vertex/registry/shell/shell-animations.ts b/apps/apollo-vertex/registry/shell/shell-animations.ts index 1e2c3ca71..d740248ae 100644 --- a/apps/apollo-vertex/registry/shell/shell-animations.ts +++ b/apps/apollo-vertex/registry/shell/shell-animations.ts @@ -26,3 +26,9 @@ export const scaleVariants = { animate: { opacity: 1, scale: 1 }, exit: { opacity: 0, scale: 0.8 }, }; + +export const edgeFadeVariants = { + initial: { opacity: 0, scaleX: 0 }, + animate: { opacity: 1, scaleX: 1 }, + exit: { opacity: 0, scaleX: 0 }, +}; diff --git a/apps/apollo-vertex/registry/shell/shell-company-logo.tsx b/apps/apollo-vertex/registry/shell/shell-company-logo.tsx index 0b4c5ffb3..40485a005 100644 --- a/apps/apollo-vertex/registry/shell/shell-company-logo.tsx +++ b/apps/apollo-vertex/registry/shell/shell-company-logo.tsx @@ -8,21 +8,34 @@ interface CompanyLogoIconProps { export const CompanyLogoIcon = ({ companyLogo }: CompanyLogoIconProps) => { if (!companyLogo) { - return ; + return ; } + // Non-custom logos (e.g. UiPath) have the background baked into the SVG — + // render them full-size so they fill the container. Custom logos are small + // marks displayed over a CSS background. + const sizeClass = companyLogo.isCustom + ? "w-4 h-auto" + : "h-8 w-8 object-contain"; + return ( <> {companyLogo.alt} { + e.currentTarget.style.display = "none"; + }} /> {companyLogo.darkUrl && ( {companyLogo.alt} { + e.currentTarget.style.display = "none"; + }} /> )} diff --git a/apps/apollo-vertex/registry/shell/shell-company.tsx b/apps/apollo-vertex/registry/shell/shell-company.tsx index 4a4bdebd1..7131a28d8 100644 --- a/apps/apollo-vertex/registry/shell/shell-company.tsx +++ b/apps/apollo-vertex/registry/shell/shell-company.tsx @@ -1,15 +1,16 @@ -import { Link } from "@tanstack/react-router"; -import { useLocalStorage } from "@mantine/hooks"; import { AnimatePresence, motion } from "framer-motion"; import { PanelLeft } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; import type { CompanyLogo } from "./shell"; import { fastFadeTransition, @@ -18,21 +19,32 @@ import { textFadeVariants, } from "./shell-animations"; import { CompanyLogoIcon } from "./shell-company-logo"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; interface CompanyProps { companyName: string; productName: string; companyLogo?: CompanyLogo; + sidebarHovered?: boolean; } interface CollapsedLogoProps { companyLogo?: CompanyLogo; + sidebarHovered: boolean; onExpand: () => void; } -function CollapsedLogo({ companyLogo, onExpand }: CollapsedLogoProps) { + +function CollapsedLogo({ + companyLogo, + sidebarHovered, + onExpand, +}: CollapsedLogoProps) { const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); + const [buttonHovered, setButtonHovered] = useState(false); + const isCustomLogo = companyLogo?.isCustom ?? false; + const panelBgClass = isCustomLogo + ? "bg-white border border-border" + : "bg-[oklch(0.6533_0.2227_34.41)]"; + const iconColorClass = isCustomLogo ? "text-black" : "text-white"; return ( @@ -40,17 +52,23 @@ function CollapsedLogo({ companyLogo, onExpand }: CollapsedLogoProps) { + {t("close_sidebar")} diff --git a/apps/apollo-vertex/registry/shell/shell-constants.ts b/apps/apollo-vertex/registry/shell/shell-constants.ts index e3c6476b9..21f51376e 100644 --- a/apps/apollo-vertex/registry/shell/shell-constants.ts +++ b/apps/apollo-vertex/registry/shell/shell-constants.ts @@ -2,10 +2,13 @@ import type { SupportedLocale } from "@/lib/i18n"; import { SUPPORTED_LOCALES } from "@/lib/i18n"; import type { TranslationKey } from "./shell-translation-key"; -export const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed"; export const THEME_STORAGE_KEY = "vss-ui-theme"; export const LANGUAGE_CHANGED_EVENT = "languageChanged"; +export type LanguageChangedEvent = { + selectedLanguageId: SupportedLocale; +}; + export const MAP_LOCALE_TO_TRANSLATION_KEY: Record< SupportedLocale, TranslationKey diff --git a/apps/apollo-vertex/registry/shell/shell-language-toggle.tsx b/apps/apollo-vertex/registry/shell/shell-language-toggle.tsx index 8dd2ac819..49bc19a32 100644 --- a/apps/apollo-vertex/registry/shell/shell-language-toggle.tsx +++ b/apps/apollo-vertex/registry/shell/shell-language-toggle.tsx @@ -1,51 +1 @@ -import { Globe } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { SupportedLocale } from "@/lib/i18n"; -import { cn } from "@/lib/utils"; -import { LANGUAGE_CHANGED_EVENT, LOCALE_OPTIONS } from "./shell-constants"; -import { Text } from "./shell-text"; - -export type LanguageChangedEvent = { - selectedLanguageId: SupportedLocale; -}; - -export function LanguageToggle() { - const { i18n } = useTranslation(); - const language = i18n.language; - - function setLanguage(code: SupportedLocale) { - document.dispatchEvent( - new CustomEvent(LANGUAGE_CHANGED_EVENT, { - detail: { selectedLanguageId: code }, - }), - ); - } - - return ( - - - - - - {LOCALE_OPTIONS.map((locale) => ( - setLanguage(locale.code)} - className={cn(language === locale.code ? "bg-accent" : "")} - > - - - ))} - - - ); -} +export type { LanguageChangedEvent } from "./shell-constants"; diff --git a/apps/apollo-vertex/registry/shell/shell-layout.tsx b/apps/apollo-vertex/registry/shell/shell-layout.tsx index d0056767b..71d728139 100644 --- a/apps/apollo-vertex/registry/shell/shell-layout.tsx +++ b/apps/apollo-vertex/registry/shell/shell-layout.tsx @@ -1,6 +1,12 @@ import type { PropsWithChildren } from "react"; +import { useId } from "react"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; import type { CompanyLogo, ShellNavItem } from "./shell"; -import { Sidebar } from "./shell-sidebar"; +import { ShellSidebar } from "./shell-sidebar"; import { useTheme } from "./shell-theme-provider"; const GRADIENT_BLUR = "blur(149.643px)"; @@ -14,6 +20,8 @@ interface ShellLayoutProps { } function DarkGradientBackground() { + const filterId = useId(); + return (
{/* Base directional wash */} @@ -74,7 +82,7 @@ function DarkGradientBackground() { className="absolute inset-0 w-full h-full opacity-[0.04]" > - + - +
); @@ -150,7 +158,7 @@ function LightGradientBackground() { function GradientBackground() { const theme = useTheme(); - if (theme.theme === "dark") { + if (theme.resolvedTheme === "dark") { return ; } return ; @@ -169,36 +177,46 @@ export function ShellLayout({
- -
- {children} -
+
{children}
); } return ( -
+ - -
-
+ +
+ +
+
{children}
-
-
+ + ); } diff --git a/apps/apollo-vertex/registry/shell/shell-locale-provider.tsx b/apps/apollo-vertex/registry/shell/shell-locale-provider.tsx index 3dfde70cb..2e3407944 100644 --- a/apps/apollo-vertex/registry/shell/shell-locale-provider.tsx +++ b/apps/apollo-vertex/registry/shell/shell-locale-provider.tsx @@ -5,7 +5,7 @@ import { useState, } from "react"; import { useTranslation } from "react-i18next"; -import { Spinner } from "@/components/ui/spinner"; +import { Skeleton } from "@/components/ui/skeleton"; import { configurei18n } from "@/lib/i18n"; import { LANGUAGE_CHANGED_EVENT } from "./shell-constants"; @@ -53,8 +53,9 @@ export const LocaleProvider = ({ children }: PropsWithChildren) => { if (!isConfigured) return ( -
- +
+ +
); diff --git a/apps/apollo-vertex/registry/shell/shell-login.tsx b/apps/apollo-vertex/registry/shell/shell-login.tsx index ff5e9c023..df5b2b26f 100644 --- a/apps/apollo-vertex/registry/shell/shell-login.tsx +++ b/apps/apollo-vertex/registry/shell/shell-login.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; import { useAuth } from "./shell-auth-provider"; export interface ShellLoginProps { @@ -28,13 +29,9 @@ export const ShellLogin = ({ title, description }: ShellLoginProps) => {
)}
- +
diff --git a/apps/apollo-vertex/registry/shell/shell-minimal-company.tsx b/apps/apollo-vertex/registry/shell/shell-minimal-company.tsx index fdaf4da35..ba4c8e3ff 100644 --- a/apps/apollo-vertex/registry/shell/shell-minimal-company.tsx +++ b/apps/apollo-vertex/registry/shell/shell-minimal-company.tsx @@ -1,4 +1,4 @@ -import { Link } from "@tanstack/react-router"; +import { cn } from "@/lib/utils"; import type { CompanyLogo } from "./shell"; import { CompanyLogoIcon } from "./shell-company-logo"; @@ -13,14 +13,21 @@ export const MinimalCompany = ({ productName, companyLogo, }: MinimalCompanyProps) => { + const isCustomLogo = companyLogo?.isCustom ?? false; + const logoBgClass = isCustomLogo + ? "bg-white border border-border" + : "bg-[oklch(0.6533_0.2227_34.41)]"; + return (
- - +
{companyName} diff --git a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx b/apps/apollo-vertex/registry/shell/shell-nav-item.tsx deleted file mode 100644 index ad22ad73b..000000000 --- a/apps/apollo-vertex/registry/shell/shell-nav-item.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Link, useLocation } from "@tanstack/react-router"; -import { useLocalStorage } from "@mantine/hooks"; -import { AnimatePresence, motion } from "framer-motion"; -import type { LucideIcon } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; -import { - fastFadeTransition, - iconHoverScale, - textFadeVariants, -} from "./shell-animations"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; -import { Text } from "./shell-text"; -import type { TranslationKey } from "./shell-translation-key"; - -interface NavItemProps { - to: string; - icon: LucideIcon; - label: TranslationKey; -} - -export const NavItem = ({ to, icon: Icon, label }: NavItemProps) => { - const [isCollapsed] = useLocalStorage({ - key: SIDEBAR_COLLAPSED_KEY, - defaultValue: false, - }); - const { pathname } = useLocation(); - const isActive = pathname === to || pathname.startsWith(`${to}/`); - - const linkContent = ( - - - - - - {!isCollapsed && ( - - - - )} - - - ); - - if (isCollapsed) { - return ( - - - {linkContent} - - - - - - ); - } - - return linkContent; -}; diff --git a/apps/apollo-vertex/registry/shell/shell-sidebar.tsx b/apps/apollo-vertex/registry/shell/shell-sidebar.tsx index affd72f03..944409691 100644 --- a/apps/apollo-vertex/registry/shell/shell-sidebar.tsx +++ b/apps/apollo-vertex/registry/shell/shell-sidebar.tsx @@ -1,16 +1,43 @@ -import { useLocalStorage } from "@mantine/hooks"; -import { motion } from "framer-motion"; +import { Link, useLocation } from "@tanstack/react-router"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDown } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + useSidebar, +} from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; import type { CompanyLogo, ShellNavItem } from "./shell"; -import { sidebarSpring } from "./shell-animations"; +import { fastFadeTransition, textFadeVariants } from "./shell-animations"; import { Company } from "./shell-company"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; import { MinimalCompany } from "./shell-minimal-company"; import { MinimalNavItem } from "./shell-minimal-nav-item"; -import { NavItem } from "./shell-nav-item"; +import { Text } from "./shell-text"; import { UserProfile } from "./shell-user-profile"; -interface SidebarProps { +const activeNavClass = + "text-sidebar-foreground/85 hover:text-sidebar-foreground data-[active=true]:text-primary-700 dark:data-[active=true]:text-primary-400 data-[active=true]:font-semibold data-[active=true]:bg-primary-100/40 dark:data-[active=true]:bg-primary-900/30"; +const navButtonClass = `font-medium ${activeNavClass}`; +const subButtonClass = activeNavClass; + +interface ShellSidebarProps { companyName: string; productName: string; variant?: "minimal"; @@ -18,20 +45,13 @@ interface SidebarProps { navItems: ShellNavItem[]; } -export const Sidebar = ({ +export const ShellSidebar = ({ companyName, productName, variant, companyLogo, navItems, -}: SidebarProps) => { - const [isCollapsed] = useLocalStorage({ - key: SIDEBAR_COLLAPSED_KEY, - defaultValue: false, - }); - - const sidebarWidth = isCollapsed ? "w-16" : "w-[280px]"; - +}: ShellSidebarProps) => { if (variant === "minimal") { return (
@@ -41,59 +61,258 @@ export const Sidebar = ({ companyLogo={companyLogo} /> - {navItems.length > 0 && ( - - )} +
- +
); } return ( - + ); +}; + +interface SidebarNavProps { + companyName: string; + productName: string; + companyLogo?: CompanyLogo; + navItems: ShellNavItem[]; +} + +function SidebarNav({ + companyName, + productName, + companyLogo, + navItems, +}: SidebarNavProps) { + const { t } = useTranslation(); + const { state, toggleSidebar } = useSidebar(); + const { pathname } = useLocation(); + const isCollapsed = state === "collapsed"; + const [sidebarHovered, setSidebarHovered] = useState(false); + + const handleMouseEnter = () => { + if (isCollapsed) setSidebarHovered(true); + }; + + const handleMouseLeave = () => { + setSidebarHovered(false); + }; + + const [expandedItems, setExpandedItems] = useState>(() => { + const expanded = new Set(); + for (const item of navItems) { + if (item.subItems?.some((sub) => pathname.startsWith(sub.path))) { + expanded.add(item.path); + } + } + return expanded; + }); + + useEffect(() => { + const expanded = new Set(); + for (const item of navItems) { + if (item.subItems?.some((sub) => pathname.startsWith(sub.path))) { + expanded.add(item.path); + } + } + setExpandedItems(expanded); + }, [pathname, navItems]); + + const handleMenuClick = (path: string) => { + if (isCollapsed) { + toggleSidebar(); + setExpandedItems((prev) => new Set(prev).add(path)); + } else { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + } + }; + + const isActive = (path: string) => { + if (path === "/") { + return pathname === "/"; + } + return pathname === path || pathname.startsWith(`${path}/`); + }; + + const isParentActive = (item: ShellNavItem) => { + return item.subItems?.some((sub) => isActive(sub.path)) ?? false; + }; + + const getTooltipText = (label: ShellNavItem["label"]): string => { + if (typeof label === "string") return t(label); + return t(label.i18nKey, label.values); + }; + + return ( + - - -
+ + + + + + + + + {navItems.map((item) => { + const Icon = item.icon; + const active = isActive(item.path); + const parentActive = isParentActive(item); + const isExpanded = expandedItems.has(item.path); + + if (item.subItems && item.subItems.length > 0) { + const showParentActive = isCollapsed && parentActive; + + return ( + handleMenuClick(item.path)} + className="group/collapsible" + > + + + + + + {!isCollapsed && ( + + + + )} + + + {!isCollapsed && ( + + + + )} + + + + + + {item.subItems.map((subItem) => { + const subActive = isActive(subItem.path); + return ( + + + + + + + + ); + })} + + + + + ); + } + + return ( + + + + + + {!isCollapsed && ( + + + + )} + + + + + ); + })} + + + + + + -
+ +
- + ); -}; +} diff --git a/apps/apollo-vertex/registry/shell/shell-theme-provider.tsx b/apps/apollo-vertex/registry/shell/shell-theme-provider.tsx index 2508272db..b0835e432 100644 --- a/apps/apollo-vertex/registry/shell/shell-theme-provider.tsx +++ b/apps/apollo-vertex/registry/shell/shell-theme-provider.tsx @@ -74,6 +74,7 @@ function applyThemeClass(resolved: "light" | "dark") { const root = window.document.documentElement; root.classList.remove("light", "dark"); root.classList.add(resolved); + root.style.colorScheme = resolved; } function applyThemeConfig(config: ThemeConfig, resolved: "light" | "dark") { diff --git a/apps/apollo-vertex/registry/shell/shell-theme-toggle.tsx b/apps/apollo-vertex/registry/shell/shell-theme-toggle.tsx deleted file mode 100644 index 72859e7ed..000000000 --- a/apps/apollo-vertex/registry/shell/shell-theme-toggle.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useLocalStorage } from "@mantine/hooks"; -import { Moon, Sun } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { SIDEBAR_COLLAPSED_KEY } from "./shell-constants"; -import { useTheme } from "./shell-theme-provider"; - -export function ThemeToggle() { - const { t } = useTranslation(); - const [isCollapsed] = useLocalStorage({ - key: SIDEBAR_COLLAPSED_KEY, - defaultValue: false, - }); - const { setTheme } = useTheme(); - - return ( - - - - - - setTheme("light")}> - {t("light")} - - setTheme("dark")}> - {t("dark")} - - setTheme("system")}> - {t("system")} - - - - ); -} diff --git a/apps/apollo-vertex/registry/shell/shell-user-profile-menu-items.tsx b/apps/apollo-vertex/registry/shell/shell-user-profile-menu-items.tsx index bf8f42fb0..061511089 100644 --- a/apps/apollo-vertex/registry/shell/shell-user-profile-menu-items.tsx +++ b/apps/apollo-vertex/registry/shell/shell-user-profile-menu-items.tsx @@ -11,7 +11,7 @@ import type { SupportedLocale } from "@/lib/i18n"; import { cn } from "@/lib/utils"; import { useAuth } from "./shell-auth-provider"; import { LANGUAGE_CHANGED_EVENT, LOCALE_OPTIONS } from "./shell-constants"; -import type { LanguageChangedEvent } from "./shell-language-toggle"; +import type { LanguageChangedEvent } from "./shell-constants"; import { Text } from "./shell-text"; import { useTheme } from "./shell-theme-provider"; diff --git a/apps/apollo-vertex/registry/shell/shell-user-profile.tsx b/apps/apollo-vertex/registry/shell/shell-user-profile.tsx index 805e00e32..043ef2716 100644 --- a/apps/apollo-vertex/registry/shell/shell-user-profile.tsx +++ b/apps/apollo-vertex/registry/shell/shell-user-profile.tsx @@ -4,110 +4,180 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { sidebarSpring } from "./shell-animations"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + fastFadeTransition, + iconHoverScale, + textFadeVariants, +} from "./shell-animations"; import { UserProfileMenuItems } from "./shell-user-profile-menu-items"; import { useUser } from "./shell-user-provider"; interface UserProfileProps { - isCollapsed: boolean; + isCollapsed?: boolean; + isMinimal?: boolean; } -export const UserProfile = ({ isCollapsed }: UserProfileProps) => { +export const UserProfile = ({ isCollapsed, isMinimal }: UserProfileProps) => { const { t } = useTranslation(); const { user } = useUser(); const userInitials = user ? user.name .split(" ") - .map((n: string) => n[0]) + .map((n) => n[0]) .join("") .toUpperCase() .slice(0, 2) : "U"; - const firstName = user?.first_name ?? t("business_user"); - const lastName = user?.last_name ?? ""; + const userName = user?.name ?? t("business_user"); + const userEmail = user?.email ?? t("user_email_placeholder"); - return ( - - {isCollapsed ? ( - - - - - - {userInitials} - - - - - -
-
- - {user?.name ?? t("business_user")} - - - {user?.email ?? t("user_email_placeholder")} - -
+ const avatarElement = ( + + + + {userInitials} + + + + ); + + if (isMinimal) { + return ( + + + + + + + + + + {userName} + + + + +
+
+ {userName} + {userEmail}
- - - - - ) : ( - - - - - - {userInitials} - - -
- - {firstName} {lastName} - - - {user?.email ?? t("user_email_placeholder")} - -
-
-
- - - -
+
+ + +
+
+ ); + } + + const trigger = ( + + ); + + const dropdownContent = isCollapsed ? ( + + +
+ {userName} + + {userEmail} + +
+
+ + +
+ ) : ( + + + + ); + + return ( + + + + + {trigger} + + + {t("user_profile")} + + + + {dropdownContent} + ); }; diff --git a/apps/apollo-vertex/registry/shell/shell.tsx b/apps/apollo-vertex/registry/shell/shell.tsx index b7b9a6bb1..b01d6392d 100644 --- a/apps/apollo-vertex/registry/shell/shell.tsx +++ b/apps/apollo-vertex/registry/shell/shell.tsx @@ -12,12 +12,19 @@ export interface CompanyLogo { url: string; darkUrl?: string; alt: string; + isCustom?: boolean; +} + +export interface ShellSubNavItem { + path: string; + label: TranslationKey; } export interface ShellNavItem { path: string; label: TranslationKey; icon: LucideIcon; + subItems?: ShellSubNavItem[]; } export interface ApolloShellProps extends PropsWithChildren { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx index 27a9b6332..0c8983f1d 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-content.tsx @@ -9,7 +9,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { data-slot="sidebar-content" data-sidebar="content" className={cn( - "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden p-2", className, )} {...props} diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx index 2b439ecfe..03d3a4cc8 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-group-label.tsx @@ -16,7 +16,7 @@ function SidebarGroupLabel({ data-slot="sidebar-group-label" data-sidebar="group-label" className={cn( - "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden focus-visible:ring-2 [&>svg]:size-5 [&>svg]:shrink-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", className, )} diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx index 2230fe5f8..f1fea2de9 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-header.tsx @@ -8,7 +8,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
); diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx index 9ed1744c7..4e3943159 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-button.tsx @@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"; import { useSidebar } from "./sidebar-provider"; const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md py-2 pl-2 pr-2 text-left text-sm text-sidebar-foreground outline-hidden ring-sidebar-ring cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:py-0! group-data-[collapsible=icon]:pr-0! group-data-[collapsible=icon]:[&>svg:not(:first-child)]:hidden [&>span]:whitespace-nowrap [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", { variants: { variant: { diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx index b45de3c3d..73cf37f72 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-menu-sub-button.tsx @@ -24,7 +24,7 @@ function SidebarMenuSubButton({ data-size={size} data-active={isActive} className={cn( - "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + "text-sidebar-foreground ring-sidebar-ring cursor-pointer hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-5 [&>svg]:shrink-0", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", size === "sm" && "text-xs", size === "md" && "text-sm", diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-provider.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-provider.tsx index d470845a8..6cec291c2 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-provider.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-provider.tsx @@ -9,6 +9,22 @@ const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; +/** Parse a CSS length value (e.g. "280px", "16rem") to pixels */ +function parseCssLengthToPx(value: string): number { + const num = Number.parseFloat(value); + if (Number.isNaN(num)) return 0; + if (value.includes("rem")) { + const rootFontSize = + typeof document === "undefined" + ? 16 + : Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ); + return num * rootFontSize; + } + return num; +} + type SidebarContextProps = { state: "expanded" | "collapsed"; open: boolean; @@ -17,6 +33,10 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; + /** Resolved expanded width in pixels (for framer-motion animations) */ + sidebarWidthPx: number; + /** Resolved collapsed icon width in pixels (for framer-motion animations) */ + sidebarWidthIconPx: number; }; const SidebarContext = React.createContext(null); @@ -62,6 +82,22 @@ export function SidebarProvider({ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; } + // Resolve CSS width values to pixels, accounting for style overrides from consumers + /* oxlint-disable typescript-eslint(no-unsafe-type-assertion) -- CSS custom property access */ + const mergedStyle = { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style, + } as Record & React.CSSProperties; + /* oxlint-enable typescript-eslint(no-unsafe-type-assertion) */ + + const sidebarWidthPx = parseCssLengthToPx( + mergedStyle["--sidebar-width"] ?? SIDEBAR_WIDTH, + ); + const sidebarWidthIconPx = parseCssLengthToPx( + mergedStyle["--sidebar-width-icon"] ?? SIDEBAR_WIDTH_ICON, + ); + // Helper to toggle the sidebar. function toggleSidebar() { return isMobile ? setOpenMobile((prev) => !prev) : setOpen((prev) => !prev); @@ -96,6 +132,8 @@ export function SidebarProvider({ openMobile, setOpenMobile, toggleSidebar, + sidebarWidthPx, + sidebarWidthIconPx, }; return ( diff --git a/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx b/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx index 9d474ae84..a034cf69b 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar-trigger.tsx @@ -21,7 +21,10 @@ function SidebarTrigger({ data-slot="sidebar-trigger" variant="ghost" size="icon" - className={cn("size-7", className)} + className={cn( + "size-7 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground", + className, + )} onClick={(event) => { onClick?.(event); toggleSidebar(); diff --git a/apps/apollo-vertex/registry/sidebar/sidebar.tsx b/apps/apollo-vertex/registry/sidebar/sidebar.tsx index 8fb73c4c4..d18ea045b 100644 --- a/apps/apollo-vertex/registry/sidebar/sidebar.tsx +++ b/apps/apollo-vertex/registry/sidebar/sidebar.tsx @@ -1,5 +1,6 @@ "use client"; +import { motion } from "framer-motion"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { @@ -12,6 +13,15 @@ import { import { cn } from "@/lib/utils"; import { useSidebar } from "./sidebar-provider"; +// Intentionally duplicated from shell-animations.ts — sidebar is a standalone +// registry item that ships independently from the shell. +const sidebarSpring = { + type: "spring", + stiffness: 400, + damping: 30, + mass: 0.5, +} as const; + const SIDEBAR_WIDTH_MOBILE = "18rem"; function Sidebar({ @@ -27,7 +37,14 @@ function Sidebar({ collapsible?: "offcanvas" | "icon" | "none"; }) { const { t } = useTranslation(); - const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); + const { + isMobile, + state, + openMobile, + setOpenMobile, + sidebarWidthPx, + sidebarWidthIconPx, + } = useSidebar(); if (collapsible === "none") { return ( @@ -69,39 +86,54 @@ function Sidebar({ ); } + const isCollapsed = state === "collapsed"; + + // Compute target width for framer-motion (matching MRS spring animation) + const gapWidth = (() => { + if (collapsible === "offcanvas" && isCollapsed) return 0; + if (collapsible === "icon" && isCollapsed) return sidebarWidthIconPx; + return sidebarWidthPx; + })(); + + const containerWidth = + collapsible === "icon" && isCollapsed ? sidebarWidthIconPx : sidebarWidthPx; + return (