"use client"; /** * UtilityBarSlot — persistent shell row for global utility actions * (Search, Notifications, What's new, Help, Onboarding, Ask Leo, Profile, and * the product switcher on the compact full-width bar). * * Compact is the only shell. Responsive density (`useUtilityBarCompact`): * Comfort (≥768px, not reflow-zoom): full action set on the bar. * Dense (≤767px or reflow): Search / What's new / Help / Onboarding collapse * into More; Notifications and Ask Leo stay pinned; school avatar stays on * the bar (no program name); profile stays on the bar. * * Settings lives in the profile menu as "Workspace settings" (same URL the * former gear used). Onboarding is on the Comfort bar as an icon, and under * More on Dense. Program name stays out of the bar (tooltip / menu only). * * On the pages that belong to no product — the products home and the sign-in flow * builder — the bar drops to the brand mark plus the genuinely global actions, * because everything else here needs a product to act on. */ import * as React from "react"; import { Link, useLocation } from "react-router"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; import { SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; import { useModKeyLabel } from "@/hooks/use-mod-key-label"; import { useScrollStuck } from "@/hooks/use-scroll-stuck"; import { useIsMobile } from "@/hooks/use-mobile"; import { useSidebarReflowZoom } from "@/hooks/use-sidebar-reflow-zoom"; import { AskLeoLauncher } from "@exxatdesignux/ui/components/shell/ask-leo-launcher"; import { usePageHeaderScrollActionsSlotRef } from "@exxatdesignux/ui/components/shell/page-header-scroll-actions"; import { requestOpenCommandMenu } from "@/components/command-menu"; import { useCompactHeaderSlotRef } from "@/components/compact-header-slot"; import { AskLeoShortcutKbds, useAskLeo } from "@/components/ask-leo-context"; import { ExxatProductMark } from "@/components/exxat-product-logo"; import { NotificationBell } from "@/components/notification-bell"; import { UtilityUserMenu } from "@/components/utility-user-menu"; import { UtilityBarProductSwitcher } from "@/components/utility-bar-product-switcher"; import { UtilityBarSchoolSwitcher } from "@/components/utility-bar-school-switcher"; import { UtilityBarWhatsNew, UtilityBarWhatsNewInMore, } from "@/components/utility-bar-whats-new"; import { useUtilityBarPageChrome } from "@/components/utility-bar-page-chrome"; import { PageBreadcrumbTrail, PageRecordMenuSwitcher, type PageBreadcrumbMenuOption, type PageBreadcrumbTrailItem, } from "@/components/page-breadcrumb-trail"; import { useShellLayout } from "@/contexts/shell-layout-context"; import { hasFlushSidebar, isCompactShell, isFullWidthUtilityBar, } from "@/lib/shell-layout"; import { isProductsHomePath } from "@/lib/product-home"; import { isSignInFlowsPath } from "@/lib/sign-in-flows-shell"; import { cn } from "@/lib/utils"; import { utilityBarActionButtonClass } from "@/components/utility-bar-chrome"; export const OPEN_SUPPORT_CHAT_EVENT = "exxat:open-support-chat"; export function requestOpenSupportChat() { window.dispatchEvent(new CustomEvent(OPEN_SUPPORT_CHAT_EVENT)); } const SUPPRESS_UTILITY_BAR_PATHS: ReadonlyArray = [ "/builder/onboarding", ]; /** * Which registry entry supplies the mark on pages with no product. The choice is * cosmetic — the round mark is brand-level and every product registers the same * corporate pink gradient for it — but naming the flagship keeps the mark off * whatever product the store happens to still be holding from the last session. */ const WORKSPACE_BRAND_MARK_PRODUCT = "exxat-prism" as const; function useUtilityBarCompact() { const isMobile = useIsMobile(); const reflowZoom = useSidebarReflowZoom(); return isMobile || reflowZoom; } export function UtilityBarSlot() { const location = useLocation(); const { variant } = useShellLayout(); const fullWidth = isFullWidthUtilityBar(variant); const compact = useUtilityBarCompact(); const pageChrome = useUtilityBarPageChrome(); const backMode = pageChrome.mode === "back"; const breadcrumbMode = pageChrome.mode === "breadcrumb"; const detailChromeMode = backMode || breadcrumbMode; if ( SUPPRESS_UTILITY_BAR_PATHS.some((path) => location.pathname.startsWith(path), ) ) return null; // Two pages sit outside every product: the products home, and the sign-in flow // builder that decides which product you land in. On both, most of this bar has // nothing to act on — search scopes to a product, Ask Leo belongs // to one, the scope picker needs one, and the sidebar toggle would toggle a // sidebar that is not rendered — and a switcher trigger reading "Clinical // Education" would name a context the page is not in. On `/home` onboarding // stays off the bar; What's new stays on the bar via the megaphone. Notifications, // Help, and support chat stay global. const withoutProduct = isProductsHomePath(location.pathname) || isSignInFlowsPath(location.pathname); const onProductsHome = isProductsHomePath(location.pathname); // Compact is this same bar with the page's breadcrumb dropped into the middle // of it, which is what lets `SiteHeader` stop drawing a second row. Not on the // product-less pages: those have no breadcrumb to show and no sidebar to // anchor it to. Back / breadcrumb mode owns the leading cluster instead of // the middle slot. const showBreadcrumb = isCompactShell(variant) && !withoutProduct && !detailChromeMode; const askLeoControl = ; // No overflow-x-hidden on the bar: that forces overflow-y to auto and clips // breadcrumb descenders inside the 42px row. Truncation lives on flex children. const barHeightClass = "relative flex h-(--shell-utility-bar-height) min-h-(--shell-utility-bar-height) min-w-0 shrink-0 items-center gap-1"; if (backMode) { return ( ); } if (breadcrumbMode) { return ( ); } return ( ); } /** * Where the page's breadcrumb lands in the compact shell. * * Sits between the product switcher and the action cluster, so the row reads * product, then location, then what you can do. `SiteHeader` portals into it * (`components/compact-header-slot.tsx`) and skips drawing its own row, which * is the whole density argument for this variant. * * Never hidden at narrow widths, even though the row is tight there. Mounting * the slot is what tells `SiteHeader` to stand down, so a `hidden` slot would * swallow the breadcrumb rather than defer it. Dense caps width and the trail * renders current page only; Comfort truncates the house + More + leaf pattern. */ function UtilityBarBreadcrumbSlot({ dense = false }: { dense?: boolean }) { const registerHeaderSlot = useCompactHeaderSlotRef(); // Crumb rule lives in UtilityBarLeadingCluster (equal pad around the product). // This slot is only the portal target for SiteHeader. return (
); } /** * Exxat mark on the pages that belong to no product. * * The leading cluster is suppressed there (no sidebar to toggle, no product to * name), which left the row with no brand on it at all. The mark is the one * piece of that cluster that still holds on a page about every product: it is * drawn from the brand registry, and every registered brand paints it in the * same corporate pink, so it says "Exxat" rather than naming a product the * user has not picked yet. * * Sits at the start of the row, where the product mark sits inside a product, * so the brand stays in one place as you move between these pages and a product. * Decorative, and deliberately not a link — on the products home the page it * would navigate to is the page it is on. */ export function WorkspaceBrandMark() { return ( ); } /** * Expand / collapse + product — toggle centered on the inset icon rail. * * Only mounted where a product is in context: the pages without one suppress the * whole cluster above, so there is no second place deciding whether the switcher * may name a product. */ export function UtilityBarLeadingCluster({ compactDensity = false, markOnly = false, showBreadcrumbDivider = false, }: { /** Mobile / high-zoom — denser product trigger so the action icons keep room. */ compactDensity?: boolean; /** Dense bar: product mark only (wordmark lives in the tip + menu). */ markOnly?: boolean; /** When true, draw the short crumb rule after the product with matching pad. */ showBreadcrumbDivider?: boolean; }) { const { isNavFlyout } = useSidebar(); const { variant } = useShellLayout(); const flush = hasFlushSidebar(variant); return (
{/* Equal inset from rail rule → product → crumb rule (fixes flush-left / over-padded-right). Focus ring clears both separators. */}
{showBreadcrumbDivider ? ( ) : null}
); } /** * Back mode leading cluster — same geometry as {@link UtilityBarLeadingCluster}: * icon slot (toggle width) · full-height rule · label (product slot). * * Record detail / focus hide the rail, but the back control still sits on the * icon-column center line so the chrome does not jump when entering the page. */ export function UtilityBarBackLeadingCluster({ href, label, scrollTitle, scrollTitleMenu, scrollTitleMenuAriaLabel, }: { href: string; label: string; scrollTitle?: string; scrollTitleMenu?: React.ComponentProps["menu"]; scrollTitleMenuAriaLabel?: string; }) { const { isNavFlyout } = useSidebar(); const { variant } = useShellLayout(); const flush = hasFlushSidebar(variant); const isStuck = useScrollStuck(); const showPageTitle = Boolean(scrollTitle) && isStuck; return (