"use client" /** * Scope trigger for a product card / marketing hero on the products home. * * Same menu as the utility bar and sidebar (`ScopeSwitcherMenuBody`) under a * wider trigger that names the scope instead of relying on a tooltip — there * is no surrounding product chrome here to give the avatar its meaning, so the * school and program have to be legible on the face of the control. * * Three triggers, same menu: * * - **`card`** — the bordered two-line control on a product card, where scope * is a standing property of the row and has the width to say so. * - **`inline`** — one line under a hero greeting. Bordered pill with a larger * school avatar so the scope reads as a control, not caption text. Pass * `stackedLabels` for program over school when the banner has room for both. * - **`compact`** — `inline`'s text with the avatar dropped, for a caption * centered under an icon-grid cell (Launcher). The avatar duplicates the * mark already sitting above it in that context and, at cell width, the * two together left almost no room for the school name before it had to * truncate — the readable-but-unlabelled avatar felt like a stray control * rather than part of one caption. * * The menu opens **over** the trigger (select-style), matching its width and * `rounded-xl` radius so the two do not stack as separate rounded boxes. * * Writes through shared scope state, so the choice made here is the one the * product reports after Open. * * When the session's scope is fixed (`scope.fixed`) the same face renders without * the menu, in the shape the unscoped "Whole workspace" line already uses: a * statement rather than a control. A student belongs to one program at one school, * so a picker there offers a choice that does not exist. */ import * as React from "react" import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { ScopeSwitcherMenuBody, type ScopeSwitcherBag, } from "@/components/scope-switcher-menu" import { cn } from "@/lib/utils" type ScopePickerVariant = "card" | "inline" | "compact" /** * Shared chrome for greeting-band pills: the program picker and Administrator. * Keep both on this string so a restyle of one cannot leave the other behind. */ export const HOME_HEADER_PILL_CLASS = "flex items-center text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring max-w-full gap-2.5 rounded-full border border-border bg-background pe-2.5 ps-2 text-sm text-muted-foreground shadow-xs hover:bg-interactive-hover hover:text-foreground data-[state=open]:bg-interactive-hover" /** * The avatar and the two names, which are the same on the button and on the * statement that replaces it when the scope is fixed. Shared so a student and a * coordinator read the same line and only one of them can press it. */ function ScopeFace({ scope, variant, stackedLabels = false, }: { scope: ScopeSwitcherBag variant: ScopePickerVariant /** Inline only: program as title, school as subtitle (Spotlight v2 greeting). */ stackedLabels?: boolean }) { const inline = variant === "inline" const compact = variant === "compact" // `inline` and `compact` both read as one line unless stackedLabels asks for two. const oneLine = (inline && !stackedLabels) || compact const { parent, child } = scope // Nothing chosen in this product yet, so there is no school logo to show and // no second line to write. One prompt in the same slot the two names use, at // the same size, so the control does not change shape once it has an answer. if (parent === null || child === null) { return ( <> {!compact ? ( ) : null} {scope.config.choosePrompt} ) } return ( <> {!compact ? ( ) : null} {oneLine ? ( inline ? ( // Inline carries the school on the avatar; name the program only. {child.name} ) : ( {child.name} {parent.name} ) ) : ( {child.name} {parent.name} )} ) } export function ProductScopePicker({ scope, variant = "card", stackedLabels = false, className, contentClassName, }: { scope: ScopeSwitcherBag variant?: ScopePickerVariant /** Inline greeting band: stack program over school on the trigger face. */ stackedLabels?: boolean className?: string /** * Extra classes on the menu surface — e.g. a higher `z-*` when this picker * opens inside a dialog that already sits above the default dropdown (`z-50`). */ contentClassName?: string }) { const inline = variant === "inline" const compact = variant === "compact" const triggerRef = React.useRef(null) // Negative bottom offset so the panel top aligns with the trigger top. const [coverOffset, setCoverOffset] = React.useState(0) React.useLayoutEffect(() => { const el = triggerRef.current if (!el) return const update = () => setCoverOffset(-el.getBoundingClientRect().height) update() const ro = new ResizeObserver(update) ro.observe(el) return () => ro.disconnect() }, [variant, scope.label, scope.parentLabel]) // Nothing licensed here, so there is nothing to press and nothing to name. A // statement rather than an empty slot, because the card still has to account // for the line its siblings fill, and rather than a menu, because a trigger // that opens an empty list is worse than no trigger. if (scope.status === "none") { return (