// Adapted from shadcn/ui (MIT). See THIRD_PARTY_NOTICES.md. "use client"; import { cloneElement, isValidElement, useImperativeHandle, useLayoutEffect, useRef, type ComponentProps, type ReactNode, type CSSProperties, } from "react"; import { Tabs as Primitive } from "./_ynrlib/radix-ui.js"; export function Tabs({ className = "", selectedColor, selectedTextColor, trackColor, style, ...props }: ComponentProps & { /** Selected background for this Tabs instance only. */ selectedColor?: string; /** Selected text for this Tabs instance only. */ selectedTextColor?: string; /** Track background for this Tabs instance only. */ trackColor?: string; }) { const colors = { ...(selectedColor ? { "--tabs-selected": selectedColor } : {}), ...(selectedTextColor ? { "--tabs-selected-color": selectedTextColor } : {}), ...(trackColor ? { "--tabs-background": trackColor } : {}), ...style, } as CSSProperties; return ( ); } export function TabsList({ className = "", children, ref, asChild, ...props }: ComponentProps) { const list = useRef(null); const indicator = useRef(null); useImperativeHandle(ref, () => list.current!, []); useLayoutEffect(() => { const element = list.current, highlight = indicator.current; if (!element || !highlight) return; let selected: Element | null = null; let animation: Animation | undefined; let last: { x: number; y: number; width: number; height: number } | undefined; const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const measure = () => { const active = Array.from( element.querySelectorAll( '[role="tab"][data-state="active"]', ), ).find((tab) => tab.closest('[role="tablist"]') === element); if (!active || !active.offsetWidth) { highlight.hidden = true; return; } const next = { x: active.offsetLeft, y: active.offsetTop, width: active.offsetWidth, height: active.offsetHeight, }; const moved = active !== selected; if ( last && Object.keys(next).every( (k) => next[k as keyof typeof next] === last![k as keyof typeof next], ) && !moved ) return; // Read the current animated position before cancelling, so rapid switches stay continuous. const current = highlight.getBoundingClientRect(); const origin = element.getBoundingClientRect(); const scaleX = origin.width / element.offsetWidth || 1, scaleY = origin.height / element.offsetHeight || 1; animation?.cancel(); highlight.hidden = false; Object.assign(highlight.style, { width: `${next.width}px`, height: `${next.height}px`, transform: `translate(${next.x}px, ${next.y}px)`, }); if (last && moved && !reduced.matches) { const x = (current.left - origin.left) / scaleX - element.clientLeft + element.scrollLeft; const y = (current.top - origin.top) / scaleY - element.clientTop + element.scrollTop; animation = highlight.animate( [ { transform: `translate(${x}px, ${y}px) scale(${current.width / scaleX / next.width}, ${current.height / scaleY / next.height})`, }, { transform: `translate(${next.x}px, ${next.y}px) scale(1, 1)` }, ], { duration: 180, easing: "cubic-bezier(.2,.8,.2,1)" }, ); } selected = active; last = next; }; const resize = new ResizeObserver(measure); const observe = () => { resize.disconnect(); resize.observe(element); element .querySelectorAll('[role="tab"]') .forEach((tab) => resize.observe(tab)); measure(); }; const mutation = new MutationObserver((records) => { if (records.some((r) => r.type === "childList")) observe(); else measure(); }); mutation.observe(element, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-state", "data-orientation", "dir", "disabled"], }); const stop = () => { if (reduced.matches) animation?.cancel(); }; reduced.addEventListener("change", stop); observe(); return () => { mutation.disconnect(); resize.disconnect(); animation?.cancel(); reduced.removeEventListener("change", stop); }; }, []); const highlight = (