"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 ? (
{parent.initials}
) : 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 (
{`No ${scope.config.childNoun.toLowerCase()}s for your account`}
)
}
if (scope.fixed) {
// Padding matches the "Whole workspace" line in `ProductScopeLine`, so the
// two statements a card can carry sit on the same grid.
return (