"use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Tabs as TabsPrimitive } from "radix-ui" import { cn } from "../../lib/utils" import { horizontalPadding, measureRowLabels, naturalRowWidth, useRowFitLadder, type RowFitMeasurement, } from "../../hooks/use-row-fit-ladder" import { useRememberedPageScroll } from "../../hooks/use-remembered-page-scroll" import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "./dropdown-menu" import { HorizontalScrollRegion, type HorizontalScrollRegionProps, } from "./horizontal-scroll-region" import { Tip } from "./tip" import { TooltipProvider } from "./tooltip" /** * Which labels the row has closed, set by {@link TabsListFit} and consumed by * {@link TabsTriggerLabel}. * * Values rather than a count, so a trigger answers "am I closed?" from the one * thing it already knows about itself. A count would have to be paired with each * trigger's position in the row, which nothing on the way down carries. */ const TabsLabelCollapseContext = React.createContext<{ /** Values whose labels show as icons right now. */ closed: ReadonlySet /** Whether this row trades labels for icons at all. */ enabled: boolean }>({ closed: new Set(), enabled: false }) const NO_LABELS_CLOSED = { closed: new Set(), enabled: false } const densityListeners = new Set<() => void>() let densityObserver: MutationObserver | null = null function subscribeShellDensity(onStoreChange: () => void) { densityListeners.add(onStoreChange) if (!densityObserver) { densityObserver = new MutationObserver(() => { for (const listener of densityListeners) listener() }) densityObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-shell-density"], }) } return () => { densityListeners.delete(onStoreChange) if (densityListeners.size === 0) { densityObserver?.disconnect() densityObserver = null } } } function readShellDensityCompact() { return document.documentElement.getAttribute("data-shell-density") === "compact" } /** * Whether the shell is in compact density. * * The shell owns the attribute and puts it on the root element; this only reads * it, so the primitive stays usable with no shell at all (the server snapshot * and the no-attribute case are both "not compact"). One observer for the * document rather than one per row: every tab row asks the same question of the * same node. */ function useCompactShellDensity() { return React.useSyncExternalStore( subscribeShellDensity, readShellDensityCompact, () => false, ) } /** * Whether the enclosing trigger renders a {@link TabsTriggerIcon}. * * Defaults to "never collapse": a `TabsTriggerLabel` rendered outside a trigger, * or in a trigger with no icon, keeps its text. */ const TabsTriggerContext = React.createContext<{ hasIcon: boolean value?: string }>({ hasIcon: false, }) /** * Radix owns `data-state`, so read it off the DOM rather than duplicating it. * Keyed on the node itself, not a ref, so a remounted trigger is re-observed * instead of leaving the observer attached to a detached element. */ function useTabsTriggerActive(node: HTMLElement | null, enabled: boolean) { const [active, setActive] = React.useState(true) React.useLayoutEffect(() => { if (!enabled || !node) return const read = () => setActive(node.getAttribute("data-state") === "active") read() const mo = new MutationObserver(read) mo.observe(node, { attributes: true, attributeFilter: ["data-state"] }) return () => mo.disconnect() }, [enabled, node]) return enabled ? active : true } /** * The current value plus a way to change it, for surfaces that must select a tab * from outside the tablist — today the overflow menu in * {@link TabsListScrollRegion}. * * Radix keeps its own selection in a private context, so {@link Tabs} mirrors it * here rather than the overflow menu faking a click on a tab that is not * rendered. */ const TabsSelectionContext = React.createContext<{ value: string | undefined select: (value: string) => void } | null>(null) function Tabs({ className, orientation = "horizontal", value, defaultValue, onValueChange, rememberScroll = false, ...props }: React.ComponentProps & { /** * Give each tab its own scroll position in the page scrollport: a tab opens at * its top the first time and where you left it after that. * * For tab rows that *are* the page's navigation — a record's module row, a * hub's views — where the panels have their own lengths and the scrollport is * shared, so the offset from the tab you left otherwise carries into the one * you asked for. Leave it off for a row inside a card or a panel: flipping a * chart between Chart and Trend must not move the page under the reader. */ rememberScroll?: boolean }) { // Controlled internally so the overflow menu can select a tab. An uncontrolled // caller keeps its API; it just no longer owns the state Radix would have kept. const [uncontrolled, setUncontrolled] = React.useState(defaultValue) const controlled = value !== undefined const current = controlled ? value : uncontrolled const [root, setRoot] = React.useState(null) useRememberedPageScroll(current, { enabled: rememberScroll, node: root, panelSelector: '[data-slot="tabs-content"]', }) const select = React.useCallback( (next: string) => { if (!controlled) setUncontrolled(next) onValueChange?.(next) }, [controlled, onValueChange], ) const selection = React.useMemo(() => ({ value: current, select }), [current, select]) const axis = React.useMemo(() => ({ orientation }), [orientation]) return ( ) } /** * The track: the surface the triggers sit on. Normally worn by `TabsList`, but * it moves to {@link TabsListShell} when an overflow menu joins the row, because * the menu trigger has to look like it is inside the track while staying out of * the `tablist` (which may contain only tabs). */ const TABS_TRACK = { default: "rounded-lg bg-muted/60 p-[3px]", line: "gap-1 border-b border-border", } as const /** Takes {@link TABS_TRACK} back off the list once the shell is wearing it. */ const TABS_TRACK_OFF = "rounded-none border-b-0 bg-transparent p-0" const tabsListVariants = cva( "group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", { variants: { variant: { default: TABS_TRACK.default, line: TABS_TRACK.line, }, }, defaultVariants: { variant: "default", }, } ) /** True when a {@link TabsListShell} above is wearing the track. */ const TabsListShellContext = React.createContext(false) /** * Radix puts `orientation` on the DOM but keeps it out of any public context, and * the overflow ladder measures width, so a vertical row has to be able to say so. */ const TabsOrientationContext = React.createContext<{ orientation: "horizontal" | "vertical" }>({ orientation: "horizontal" }) export interface TabsOverflowProps { /** * Set `false` to keep the row at its natural width and let it overflow or wrap * on its own terms. Only correct when something else already handles the * overflow, e.g. a `flex-wrap` list. */ overflow?: boolean /** Accessible name for the scroll region around the row. */ ariaLabel?: string /** Set `false` to always show every label and shed whole tabs instead. */ collapseLabels?: boolean /** Set `false` to scroll the row instead of moving tabs into a menu. */ overflowMenu?: boolean /** Accessible name for the overflow trigger. */ overflowLabel?: string } /** Config from a {@link TabsListScrollRegion} down to the list it wraps. */ const TabsOverflowContext = React.createContext< (TabsOverflowProps & { regionClassName?: string; scrollClassName?: string }) | null >(null) /** The `tablist` itself, with no overflow machinery around it. */ function TabsListRoot({ className, variant = "default", inShell, outermost, ...props }: React.ComponentProps & VariantProps & { /** A shell is wearing the track, so give it up. */ inShell?: boolean /** Nothing wraps this list, so it owns its own alignment. */ outermost?: boolean }) { return ( ) } /** * The `tablist`, which handles its own overflow. * * Overflow is not a per-surface decision, so it is not something a call site has * to remember to opt into: any row narrower than its tabs sheds them through the * ladder in {@link useRowFitLadder}. {@link TabsListScrollRegion} exists to name * the region or opt out, not to switch the behaviour on. */ function TabsList({ overflow, ariaLabel, collapseLabels, overflowMenu, overflowLabel, className, variant: variantProp, pinVariant = false, children, ...props }: React.ComponentProps & VariantProps & TabsOverflowProps & { /** * Keep `variant` exactly as passed, ignoring shell density. * * Only correct for a surface that *demonstrates* a variant rather than uses * one — the design system catalog. A product surface should stay adaptive, * so the shell can decide how its tabs read. */ pinVariant?: boolean }) { const inShell = React.useContext(TabsListShellContext) const region = React.useContext(TabsOverflowContext) const { orientation } = React.useContext(TabsOrientationContext) const compactShell = useCompactShellDensity() // `VariantProps` admits `null`, and the track is keyed by variant name. const requested = variantProp ?? "default" // The compact shell drops the pill: on a square, edge-to-edge canvas a filled // track reads as a floating control, and it repeats the boxed-in look the rest // of that variant removes. Underline says the same thing with a rule. Resolved // here rather than at every call site, because it is a property of the shell // rather than of any one surface. Horizontal only — the line track is a // `border-b`, which a vertical column has nothing to draw against. const variant = !pinVariant && compactShell && requested === "default" && orientation === "horizontal" ? "line" : requested const settings = { overflow: overflow ?? region?.overflow ?? true, ariaLabel: ariaLabel ?? region?.ariaLabel ?? "Tabs", collapseLabels: collapseLabels ?? region?.collapseLabels ?? true, overflowMenu: overflowMenu ?? region?.overflowMenu ?? true, overflowLabel: overflowLabel ?? region?.overflowLabel ?? "More tabs", } // `inShell` means this list is already inside its own machinery, one render // down. Vertical rows are bounded by height, which none of this measures. if (inShell || !settings.overflow || orientation === "vertical") { const list = ( {children} ) return inShell ? list : {list} } return ( {children} ) } /** * Wears the track on behalf of a {@link TabsList} so an overflow trigger can * share it. One control to the eye, a tablist plus a button to the DOM. */ function TabsListShell({ variant = "default", stickySubheader = false, children, }: { variant?: "default" | "line" /** Sticky strip already draws the full-width rule — drop the short track border. */ stickySubheader?: boolean children: React.ReactNode }) { return ( {children} ) } /** * Runs the ladder for one row: measures it, sheds labels, then tabs, and keeps * the selected tab on the row throughout. * * The region has to stretch to the space the row is allowed, because a row that * hugs its own content can never be measured as too wide. The `tablist` inside * keeps `w-fit`, so tabs stay left aligned and the surplus clips into the * viewport instead of pushing past the parent. * * The strip is sticky under the utility bar: page content scrolls in * `[data-page-scroll]` while the bar sits outside that scrollport, so `top-0` * pins the strip to the top of the canvas. Height matches * `--shell-utility-bar-height`. A full-width `border-b` is the subheader rule * (line-variant lists drop their short track border so the rule spans the row). */ function TabsListFit({ variant = "default", className, regionClassName, scrollClassName, ariaLabel, collapseLabels, overflowMenu, overflowLabel, listProps, children, }: { variant?: "default" | "line" className?: string regionClassName?: string scrollClassName?: string ariaLabel: string collapseLabels: boolean overflowMenu: boolean overflowLabel: string listProps: Record children: React.ReactNode }) { const viewportRef = React.useRef(null) const selection = React.useContext(TabsSelectionContext) const triggers = React.useMemo(() => findTriggers(children), [children]) // Only triggers built from both slots can trade a label for an icon. const canCollapse = React.useMemo( () => collapseLabels && triggers.some( trigger => hasTriggerIcon(trigger.props.children) && findTriggerLabelText(trigger.props.children) !== null, ), [collapseLabels, triggers], ) const measure = React.useCallback((): RowFitMeasurement | null => { const viewport = viewportRef.current const content = viewport?.firstElementChild as HTMLElement | null if (!viewport || !content) return null return { available: viewport.clientWidth - horizontalPadding(viewport), // The tablist is the one descendant that clamps the same way its parent // does, so it is the one worth recursing into. content: naturalRowWidth(content, "[data-slot='tabs-list']"), labels: measureRowLabels( content, label => label.closest("[data-slot='tabs-trigger']")?.dataset.state === "active", "[data-slot='tabs-trigger-label'][data-collapsible]", ), } }, [variant, className]) const canShed = overflowMenu && triggers.length > 1 const { openLabels, visible } = useRowFitLadder({ viewportRef, count: triggers.length, canCollapse, enabled: canCollapse || canShed, measure, }) let shown = triggers let hidden: TriggerElement[] = [] if (canShed && visible < triggers.length) { shown = triggers.slice(0, visible) hidden = triggers.slice(visible) const active = selection?.value if (active && !shown.some(trigger => trigger.props.value === active)) { const activeTrigger = hidden.find(trigger => trigger.props.value === active) if (activeTrigger) { // Swap, rather than widen the row: the selected tab takes the last slot // and the tab it displaced goes into the menu. shown = [...shown.slice(0, -1), activeTrigger] const onRow = new Set(shown) hidden = triggers.filter(trigger => !onRow.has(trigger)) } } } // Which labels the ladder has closed: the collapsible ones on the row, in row // order, past the point it could still afford. Derived from `shown` rather // than from every trigger, because that is the order the row was measured in. const closedValues = shown .filter( trigger => hasTriggerIcon(trigger.props.children) && findTriggerLabelText(trigger.props.children) !== null, ) .map(trigger => trigger.props.value as string) .slice(openLabels) // Keyed on the set's contents so an unrelated render does not hand every // trigger a new context value and re-render the row for nothing. const closedKey = closedValues.join("\u0000") const labelCollapse = React.useMemo( () => ({ closed: new Set(closedKey ? closedKey.split("\u0000") : []), enabled: canCollapse, }), [closedKey, canCollapse], ) // Line track's short `border-b` would only span the w-fit list; the sticky // strip owns the full-width rule instead. const listClassName = cn(className, variant === "line" && "border-b-0") const list = ( 0} {...listProps} > {hidden.length > 0 ? shown : children} ) return (
{hidden.length > 0 ? ( // The trigger renders inside the track but outside the tablist, so the // two read as one control without putting a button among the tabs. {list} ) : ( list )}
) } /** * Marks the glyph in a {@link TabsTrigger}. Presence of this slot is what makes * a trigger eligible to collapse to icon only, so a label-only tab can never * collapse into nothing. */ function TabsTriggerIcon({ className, ...props }: React.ComponentProps<"span">) { return ( ) } /** * Marks the text in a {@link TabsTrigger}. Beside an icon, it gives its width * back to the row when there is not enough of it, leaving the tab as its glyph. * * The text stays in the DOM rather than being replaced by the icon, so the tab * keeps its accessible name and a screen reader still announces it by label. * What goes away is the column it sits in: a grid track from `1fr` to `0fr`, * which unlike `display: none` is a length, and so can be crossed rather than * jumped. `overflow: hidden` on the inner span is what lets the track reach * zero — without it the text's own `min-width: auto` floors the column at its * full width. * * Labels close from the end of the row and only as far as they have to, so a row * one label too wide gives up one label rather than all of them. Which ones are * closed comes from the ladder; whether the selected tab honours it is CSS, off * Radix's `data-state`. Keeping the exemption in CSS means selecting another tab * re-opens its label without a render, so the row cannot be measured in the gap * between the click and the state that would have followed it. * * Pair with {@link TabsTriggerIcon}; without an icon this renders unchanged. */ function TabsTriggerLabel({ className, children, ...props }: React.ComponentProps<"span">) { const { closed, enabled } = React.useContext(TabsLabelCollapseContext) const { hasIcon, value } = React.useContext(TabsTriggerContext) if (!hasIcon || !enabled) { return ( {children} ) } const closedNow = value !== undefined && closed.has(value) return ( {children} ) } /** Text of the {@link TabsTriggerLabel} child, for the collapsed tooltip. */ function findTriggerLabelText(children: React.ReactNode): string | null { for (const child of React.Children.toArray(children)) { if (!React.isValidElement(child) || child.type !== TabsTriggerLabel) continue const label = (child.props as { children?: React.ReactNode }).children if (typeof label === "string") return label if (typeof label === "number") return String(label) } return null } function hasTriggerIcon(children: React.ReactNode): boolean { return React.Children.toArray(children).some( (child) => React.isValidElement(child) && child.type === TabsTriggerIcon, ) } function TabsTrigger({ className, children, ref: forwardedRef, ...props }: React.ComponentProps) { const { closed } = React.useContext(TabsLabelCollapseContext) const [node, setNode] = React.useState(null) const hasIcon = React.useMemo(() => hasTriggerIcon(children), [children]) const value = typeof props.value === "string" ? props.value : undefined const triggerState = React.useMemo(() => ({ hasIcon, value }), [hasIcon, value]) const closedNow = value !== undefined && closed.has(value) // Only the tooltip needs to know this, and a tooltip does not affect layout. // The label's own collapse is CSS, so it cannot lag a render behind the // measurement that decides whether the row still overflows. const active = useTabsTriggerActive(node, closedNow && hasIcon) const attach = React.useCallback( (element: HTMLButtonElement | null) => { setNode(element) if (typeof forwardedRef === "function") forwardedRef(element) else if (forwardedRef) { ;(forwardedRef as React.MutableRefObject).current = element } }, [forwardedRef], ) const labelText = findTriggerLabelText(children) const collapsed = closedNow && hasIcon && !active let body: React.ReactNode = ( {children} ) if (hasIcon && labelText) { // Stable wrapper so the button never remounts when the tooltip comes and // goes: a remounted button would strand the `data-state` observer above. body = {body} // Collapsed to a glyph, the tooltip is the only way a sighted user can read // the tab. It must hang off this inner span rather than the button, because // Radix Tooltip's trigger writes its own `data-state` and would overwrite // the tab's active/inactive state, silently killing the selected underline. // // Mounted whether or not it can open, because mounting it re-parents this // span and so rebuilds the label inside it. Doing that at the moment the // row collapses would hand the browser a label that has never been drawn // wide, and a label with no previous width has nothing to shrink from. body = ( {body} ) } return ( {body} ) } function TabsContent({ className, ...props }: React.ComponentProps) { return ( ) } type TriggerElement = React.ReactElement> /** Triggers carrying a `value`, which is what the overflow menu selects by. */ function findTriggers(children: React.ReactNode): TriggerElement[] { return React.Children.toArray(children).filter( (child): child is TriggerElement => React.isValidElement(child) && child.type === TabsTrigger && typeof (child.props as { value?: unknown }).value === "string", ) } /** * Tabs that did not fit, as menu items. * * A radio group rather than plain items: picking a tab is choosing one of a set, * and `menuitemradio` is the only role that says so. The label collapse is turned * off inside, so a tab showing as an icon in the row still reads as text here. */ function TabsOverflowMenu({ triggers, label, }: { triggers: TriggerElement[] label: string }) { const selection = React.useContext(TabsSelectionContext) return (