// ~/src/siteplane/components/tabs/tabs.tsx "use client"; import { mergeProps } from "@base-ui/react/merge-props"; import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; import { useRender } from "@base-ui/react/use-render"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import * as React from "react"; import { type SiteplaneControlSize, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import { Button } from "../button"; import { KitButton } from "../button/button-internal"; import { useLeavingPane } from "../motion/motion-follow"; import { panelSwitchClassName } from "../motion/motion-presence"; import { layoutSize, railIndicatorGeometry } from "./tabs-geometry"; import { useTabsOverflow } from "./tabs-overflow"; export type TabsVariant = "default" | "pill" | "underline"; const TabsSizeContext = React.createContext(null); const TabsVariantContext = React.createContext("default"); export interface TabsProps extends Omit< WithStringClassName, "size" > { size?: SiteplaneControlSize; } export function Tabs({ className, size, ...props }: TabsProps): React.ReactElement { const resolvedSize = useResolvedControlSize(size); return ( .sp-tabs-list-frame]:shrink-0 [&>.sp-tabs-list-frame>.sp-tabs-list]:shrink-0 [&>.sp-tabs-rail]:shrink-0", className, )} data-size={resolvedSize} data-slot="tabs" {...props} /> ); } /** * Places a rail's selection on its active tab: the target given, or the * selected tab. It reads every box before it writes, so a placement forces one * layout. Returns whether the target has a size, as Base UI shows its own * indicator only then. */ function placeRailIndicator( rail: HTMLElement, indicator: HTMLElement, active?: HTMLElement | null, ): boolean { const target = active ?? rail.querySelector('[role="tab"][aria-selected="true"]'); if (!target) return false; // Scrolled tabs must not paint their selection under the adjacent action. // The strip that shows the tabs is the list, or the scroll viewport of a // ScrollArea that holds the list inside this rail. const list = target.closest('[role="tablist"]'); const viewport = list?.closest( '[data-slot="scroll-area-viewport"]', ); const strip = viewport && rail.contains(viewport) ? viewport : list; // The boxes are read on screen and turned into the rail's own px, so a rail // in a popup that still zooms in places its selection on the tab, not beside // it (tabs-geometry.ts). const place = railIndicatorGeometry({ rail: rail.getBoundingClientRect(), railSize: layoutSize(rail), target: target.getBoundingClientRect(), targetSize: layoutSize(target), strip: strip?.getBoundingClientRect(), }); indicator.style.setProperty("--active-tab-left", `${place.left}px`); indicator.style.setProperty("--active-tab-width", `${place.width}px`); indicator.style.setProperty("--active-tab-height", `${place.height}px`); indicator.parentElement!.style.clipPath = strip ? `inset(0 ${place.clipEnd}px 0 ${place.clipStart}px)` : "inset(0)"; return place.width > 0 && place.height > 0; } /** Shared rail and selection indicator for tabs composed with an adjacent action. */ export function TabsRail({ className, children, activeIndicatorRef, ref, variant = "default", ...props }: React.ComponentProps<"div"> & { activeIndicatorRef?: React.RefObject | undefined; variant?: "default" | "pill" | undefined; }): React.ReactElement { const railRef = React.useRef(null); const indicatorRef = React.useRef(null); // As with Base UI's indicator in a list, the rail's indicator stays hidden // until it has a place, and the active tab paints the same selection until // then (design-rework.css, "pending"). The server HTML shows the selection // that way, with no inline script; the layout effect below hands it to the // indicator before the first paint after hydration, and the indicator // appears in place, without a transition. const [placed, setPlaced] = React.useState(false); React.useImperativeHandle(ref, () => railRef.current as HTMLDivElement); React.useLayoutEffect(() => { const rail = railRef.current; const indicator = indicatorRef.current; if (!rail || !indicator) return; function measure() { setPlaced( placeRailIndicator(rail!, indicator!, activeIndicatorRef?.current), ); } measure(); const resize = new ResizeObserver(measure); resize.observe(rail); for (const element of rail.querySelectorAll( '[role="tab"], button', )) { resize.observe(element); } const selection = new MutationObserver(measure); selection.observe(rail, { attributes: true, attributeFilter: ["aria-selected"], subtree: true, }); rail.addEventListener("scroll", measure, true); return () => { resize.disconnect(); selection.disconnect(); rail.removeEventListener("scroll", measure, true); }; }, [activeIndicatorRef]); return (
{children}
); } export interface TabsListProps extends Omit< WithStringClassName, "size" > { variant?: TabsVariant; embedded?: boolean; /** * Underline variant: a hairline under all tabs, along their side when * vertical, that the active underline sits on. It spans the list, so a list * widened with a class such as `w-full` carries it further; the tabs stay at * the start. */ divider?: boolean; size?: SiteplaneControlSize; } /** * The button at one edge of a list whose tabs overflow, shown while more tabs * wait in its direction. It is a pointer control: keyboard users move through * the tabs with the arrow keys, which scroll the list, so the button stays out * of the tab order and of the accessibility tree, and a press keeps the focus * where it was. Its wrapper fades it in and out; the Button keeps its own * states (the muted inline action, rulebook T2). A touch screen hides it, as * the list scrolls by swiping there (design-rework.css). */ function TabsScrollButton({ direction, onScroll, shown, size, visible, }: { direction: "end" | "start"; onScroll: (direction: "end" | "start") => void; /** The list overflows, so the button holds its place. */ shown: boolean; size: SiteplaneControlSize; /** More tabs wait in its direction. */ visible: boolean; }): React.ReactElement { const Icon = direction === "start" ? ChevronLeftIcon : ChevronRightIcon; return ( ); } export function TabsList({ divider = false, embedded = false, variant = "default", size, className, children, ref, ...props }: TabsListProps): React.ReactElement { const groupSize = React.useContext(TabsSizeContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); const listRef = React.useRef(null); const { overflow, scrollToward } = useTabsOverflow(listRef); const setListRef = React.useCallback( (element: HTMLDivElement | null) => { listRef.current = element; if (typeof ref === "function") ref(element); else if (ref) ref.current = element; }, [ref], ); return ( {/* The frame adds no box while the tabs fit. When they overflow, it lays a scroll button on either side of the list, which shows while more tabs wait in its direction; a touch screen swipes the list instead and keeps no room for them (design-rework.css). */}
{/* Base UI keeps the indicator hidden until it has measured the active tab. Until then the active tab paints the same selection (design-rework.css), so the selection shows from the first paint without an inline script, and the indicator takes over in place once it shows. The indicator leads the list, so that rule reaches the tabs as its siblings. */} {!embedded && ( )} {children}
); } export interface TabsTabProps extends Omit< WithStringClassName, "size" > { size?: SiteplaneControlSize; } export function TabsTab({ className, size, render, ...props }: TabsTabProps): React.ReactElement { const groupSize = React.useContext(TabsSizeContext); const variant = React.useContext(TabsVariantContext); const resolvedSize = useResolvedControlSize(size ?? groupSize); return ( ) } {...props} /> ); } /** The panel that takes over: the one that is shown and not on its way out. */ const enteringPanel = (container: HTMLElement) => container.querySelector( ':scope > [data-slot="tabs-content"]:not([data-ending-style]):not([hidden])', ); /** * Switching tabs shows the new panel at once (rulebook M8): the panel that * leaves is hidden the moment the new one shows, and the indicator and the * height of the panels' container carry the motion. No moment shows two panels * or none. Nothing to set up; a panel with `keepMounted` keeps its state, which * a `MotionSwap` would remount, so Base UI keeps mounting the panels and the * motion component pins the one that leaves while the container follows. */ export function TabsPanel({ className, render, ...props }: WithStringClassName): React.ReactElement { return ( ( )} {...props} /> ); } function TabsPanelSwap({ panelProps, render, state, }: { panelProps: React.ComponentPropsWithRef<"div">; render: TabsPrimitive.Panel.Props["render"]; state: TabsPrimitive.Panel.State; }): React.ReactElement { const ref = React.useRef(null); // Base UI keeps the panel mounted while it leaves, `ending` until its fade // has run, and unmounts or hides it after. const leaving = state.transitionStatus === "ending"; const style = useLeavingPane(ref, { enter: enteringPanel, leaving, open: !state.hidden && !leaving, }); return useRender({ defaultTagName: "div", props: mergeProps<"div">(panelProps, { style }), ref, render: render as useRender.ComponentProps<"div">["render"], state: { ...state }, }); } export { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent }; // ~/src/siteplane/components/tabs/tabs-overflow.ts "use client"; import * as React from "react"; import { type ScrollMotion, scrollInlineTo } from "../motion/motion-scroll"; /** Where a list that overflows has more tabs: before the visible ones, after them, or both. */ export type TabsOverflow = { end: boolean; start: boolean }; /** * The horizontal overflow of a tab list (S-13), measured whenever the list, a * tab or the scroll position changes, and the scrolling that reveals tabs: the * selected tab when the list starts to overflow and whenever the selection * changes, and a page of tabs for each scroll button. A vertical list never * overflows here. Keyboard focus scrolls its tab into view itself, as the * browser does for any element that takes focus. */ export function useTabsOverflow( listRef: React.RefObject, ): { overflow: TabsOverflow | null; scrollToward: (direction: "end" | "start") => void; } { const [overflow, setOverflow] = React.useState(null); const animationRef = React.useRef(null); // The list scrolls through the motion component, over the tokens of a list // that scrolls to a target, and at once under reduced motion. const scrollTo = React.useCallback( (left: number, smooth: boolean) => { const list = listRef.current; if (!list) return; animationRef.current?.stop(); // scrollLeft runs from 0 toward the end in LTR and from 0 toward // negative values in RTL; the target stays within that range. const extent = list.scrollWidth - list.clientWidth; const rtl = getComputedStyle(list).direction === "rtl"; const target = rtl ? Math.min(0, Math.max(-extent, left)) : Math.max(0, Math.min(extent, left)); animationRef.current = scrollInlineTo(list, target, smooth); }, [listRef], ); /** Scrolls the list just far enough to show a tab whole, clear of the list's inset. */ const reveal = React.useCallback( (tab: HTMLElement, smooth: boolean) => { const list = listRef.current; if (!list) return; const box = list.getBoundingClientRect(); const tabBox = tab.getBoundingClientRect(); const style = getComputedStyle(list); const left = box.left + list.clientLeft; const before = tabBox.left - (left + (Number.parseFloat(style.paddingLeft) || 0)); const after = tabBox.right - (left + list.clientWidth - (Number.parseFloat(style.paddingRight) || 0)); if (before < 0) scrollTo(list.scrollLeft + before, smooth); else if (after > 0) scrollTo(list.scrollLeft + after, smooth); }, [listRef, scrollTo], ); const scrollToward = React.useCallback( (direction: "end" | "start") => { const list = listRef.current; if (!list) return; // A page is as many whole tabs as fit in the visible width, and the // visible width when a single tab is wider. let page = 0; for (const tab of list.querySelectorAll('[role="tab"]')) { const width = tab.getBoundingClientRect().width; if (page + width > list.clientWidth) { if (page === 0) page = list.clientWidth; break; } page += width; } const rtl = getComputedStyle(list).direction === "rtl"; const sign = (direction === "end" ? 1 : -1) * (rtl ? -1 : 1); scrollTo(list.scrollLeft + sign * page, true); }, [listRef, scrollTo], ); React.useLayoutEffect(() => { const list = listRef.current; if (!list) return; const measure = () => { const extent = list.scrollWidth - list.clientWidth; const next = list.getAttribute("aria-orientation") === "vertical" || extent <= 1 ? null : { end: Math.abs(list.scrollLeft) < extent - 1, start: Math.abs(list.scrollLeft) > 1, }; setOverflow((current) => current?.start === next?.start && current?.end === next?.end ? current : next, ); }; measure(); const resize = new ResizeObserver(measure); const observeTabs = () => { resize.observe(list); for (const tab of list.querySelectorAll('[role="tab"]')) resize.observe(tab); }; observeTabs(); // A selection made outside the list, or a click on a tab at its edge, // brings the selected tab into view; tabs that join are measured too. const mutations = new MutationObserver((records) => { for (const record of records) { if (record.type === "childList") observeTabs(); const tab = record.target; if ( record.type === "attributes" && tab instanceof HTMLElement && tab.getAttribute("aria-selected") === "true" ) reveal(tab, true); } measure(); }); mutations.observe(list, { attributeFilter: ["aria-selected"], attributes: true, childList: true, subtree: true, }); // A scroll by hand stops a scroll the kit runs. const stop = () => animationRef.current?.stop(); list.addEventListener("scroll", measure, { passive: true }); list.addEventListener("wheel", stop, { passive: true }); list.addEventListener("pointerdown", stop, { passive: true }); return () => { resize.disconnect(); mutations.disconnect(); list.removeEventListener("scroll", measure); list.removeEventListener("wheel", stop); list.removeEventListener("pointerdown", stop); animationRef.current?.stop(); }; }, [listRef, reveal]); // When the list starts to overflow, such as after its first layout, it // shows the selected tab at once. const overflowing = overflow !== null; React.useLayoutEffect(() => { if (!overflowing) return; const selected = listRef.current?.querySelector( '[role="tab"][aria-selected="true"]', ); if (selected) reveal(selected, false); }, [listRef, overflowing, reveal]); return { overflow, scrollToward }; } // ~/src/siteplane/components/tabs/tabs-geometry.ts /** A box measured on screen (getBoundingClientRect): transforms scale it. */ export interface ScreenBox { left: number; right: number; width: number; } /** A box's size in layout px, which transforms do not scale. */ export interface LayoutSize { width: number; height: number; } /** * An element's size in layout px: its computed width and height, or its offset * size where the computed size is no length, as Floating UI's getCssDimensions * reads it. */ export function layoutSize(element: HTMLElement): LayoutSize { const style = getComputedStyle(element); const width = Number.parseFloat(style.width) || 0; const height = Number.parseFloat(style.height) || 0; return Math.round(width) === element.offsetWidth && Math.round(height) === element.offsetHeight ? { width, height } : { width: element.offsetWidth, height: element.offsetHeight }; } /** * Where a rail's indicator sits, in the rail's own layout px. The boxes are * measured on screen, so inside a scaled ancestor, such as a Dialog popup that * zooms in while it opens, every distance is scaled as well. Each one is * divided by the rail's scale, the way Base UI's Tabs.Indicator measures its * tab; the size comes from the tab's layout box. A rail scaled to nothing keeps * the screen distances. */ export function railIndicatorGeometry({ rail, railSize, target, targetSize, strip, }: { rail: ScreenBox; railSize: LayoutSize; target: ScreenBox; targetSize: LayoutSize; strip?: ScreenBox | undefined; }): { left: number; width: number; height: number; clipStart: number; clipEnd: number; } { const scale = railSize.width > 0 ? rail.width / railSize.width : 1; const unscale = (length: number) => scale > Number.EPSILON ? length / scale : length; return { left: unscale(target.left - rail.left), width: targetSize.width, height: targetSize.height, clipStart: strip ? Math.max(0, unscale(strip.left - rail.left)) : 0, clipEnd: strip ? Math.max(0, unscale(rail.right - strip.right)) : 0, }; } // ~/src/siteplane/components/tabs/index.ts export * from "./tabs";