'use no memo'; // composition props usually invalidate the generated wrapper caches
import {
type ComponentPropsWithoutRef,
type ComponentType,
createContext,
type ReactNode,
type Ref,
type RefObject,
type SVGProps,
use,
useEffect,
useRef,
} from 'react';
import { Tabs as BaseTabs } from '@base-ui/react/tabs';
import { clsx } from 'clsx';
import * as styles from '#/components/Tabs.css';
import { Text } from '#/components/Text';
/** controls whether tabs fill the row or fit their labels. */
export type TabsVariant = 'fill' | 'hug';
const TabsContext = createContext<{ value: string; variant: TabsVariant } | null>(null);
/**
* enables click-and-drag horizontal scrolling for an overflowing container.
*
* prevents click actions from triggering if the drag distance exceeds a small threshold.
*/
const useDragScroll = (ref: RefObject<HTMLElement | null>) => {
useEffect(() => {
const row = ref.current;
if (!row) {
return;
}
let isPressed = false;
let isDragging = false;
let startX = 0;
let startScrollLeft = 0;
const onMouseDown = (e: MouseEvent) => {
isPressed = true;
startX = e.pageX;
startScrollLeft = row.scrollLeft;
};
const onMouseMove = (e: MouseEvent) => {
if (!isPressed) {
return;
}
const walk = e.pageX - startX;
if (!isDragging && Math.abs(walk) < 3) {
return;
}
isDragging = true;
e.preventDefault();
row.scrollLeft = startScrollLeft - walk;
};
const onMouseUp = () => {
if (isDragging) {
// the bar sits below React's root click delegation, so stopping the trailing click here keeps
// the drag from selecting the tab it lands on
row.addEventListener('click', (e) => e.stopPropagation(), { once: true });
}
isPressed = false;
isDragging = false;
};
row.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
row.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
}, [ref]);
};
export type RootProps = Omit<ComponentPropsWithoutRef<'div'>, 'onChange'> & {
onValueChange: (value: string) => void;
ref?: Ref<HTMLDivElement>;
value: string;
variant?: TabsVariant;
};
export const Root = ({
children,
className,
onValueChange,
ref,
value,
variant = 'fill',
...rest
}: RootProps) => {
return (
<TabsContext value={{ value, variant }}>
<BaseTabs.Root
ref={ref}
value={value}
// Base UI types a tab value as `any`; this wrapper only ever keys its tabs by string
onValueChange={(next: string) => onValueChange(next)}
className={clsx(styles.root, className)}
{...rest}
>
{children}
</BaseTabs.Root>
</TabsContext>
);
};
export type ListProps = ComponentPropsWithoutRef<'div'>;
export const List = ({ className, children, ...rest }: ListProps) => {
const listRef = useRef<HTMLDivElement>(null);
const { value } = use(TabsContext)!;
// when the row overflows, center the active tab in it — scoped to the list's own scrollLeft, since
// scrollIntoView would also scroll ancestors (e.g. nudge the page vertically). the browser clamps
// scrollLeft to its range, so the first/last tabs rest against the edge instead of over-scrolling.
useEffect(() => {
const list = listRef.current;
const tab = list?.querySelector(`.${styles.tab}[data-active]`);
if (!list || !tab) {
return;
}
const listRect = list.getBoundingClientRect();
const tabRect = tab.getBoundingClientRect();
const dx = tabRect.left + tabRect.width / 2 - (listRect.left + listRect.width / 2);
if (dx !== 0) {
list.scrollBy({ behavior: 'smooth', left: dx });
}
// oxlint-disable-next-line react/exhaustive-effect-dependencies -- value triggers recentering
}, [value]);
useDragScroll(listRef);
return (
<BaseTabs.List ref={listRef} className={clsx(styles.list, className)} {...rest}>
{children}
</BaseTabs.List>
);
};
/** an SVG tab icon. */
export type TabIcon = ComponentType<SVGProps<SVGSVGElement>>;
export type TabProps = Omit<ComponentPropsWithoutRef<'button'>, 'value' | 'children'> & {
icon?: TabIcon;
value: string;
label: string;
};
export const Tab = ({ value, className, icon: Icon, label, ...rest }: TabProps) => {
const { variant } = use(TabsContext)!;
return (
<BaseTabs.Tab
value={value}
className={clsx(styles.tab, styles.tabVariant[variant], className)}
render={(props, state) => (
<button {...props}>
<span className={styles.tabContent}>
{Icon && <Icon className={styles.tabIcon} />}
<Text color={state.active ? 'text' : 'textContrastMedium'}>{label}</Text>
</span>
</button>
)}
{...rest}
/>
);
};
export type PanelProps = Omit<ComponentPropsWithoutRef<'div'>, 'value'> & {
value: string;
};
export const Panel = ({ value, className, children, ...rest }: PanelProps) => {
return (
<BaseTabs.Panel value={value} className={clsx(styles.panel, className)} {...rest}>
{children}
</BaseTabs.Panel>
);
};
export type Section<Id extends string> = {
/** content shown after the active tab. */
actions?: ReactNode;
children: ReactNode;
/** an optional icon before the label. */
icon?: TabIcon;
id: Id;
label: string;
};
export type TabsProps<Id extends string> = {
/** Rendered above the tab bar, in flow — it scrolls away while the bar stays sticky. */
header?: ReactNode;
/** Sticky offset in px for the tab bar, e.g. the height of a persistent header above it. */
headerOffset?: number;
/**
* Called when the already-active tab is re-tapped. Defaults to scrolling the page to the top; the feed
* screens that own a soft-reset pass that instead.
*/
onTabReselect?: (id: Id) => void;
onValueChange: (value: Id) => void;
sections: Section<Id>[];
value: Id;
variant?: TabsVariant;
};
/**
* renders a tabbed interface with a sticky tab bar.
*
* @param props.sections config array for the tabs and panels
* @param props.value active tab value
* @param props.onValueChange callback triggered on tab change
* @param props.header optional header element rendered above the tab bar
* @param props.variant whether the tabs fill the bar or hug their labels
*/
export const Tabs = <Id extends string>({
header,
headerOffset = 0,
onTabReselect,
onValueChange,
sections,
value,
variant = 'fill',
}: TabsProps<Id>) => {
const rootRef = useRef<HTMLDivElement>(null);
// a value that no longer matches any section (dynamic tab sets shrink as filters apply) falls back to
// the first tab, so the bar and panels stay in sync with what's actually selectable
const activeSection = sections.find((section) => section.id === value) ?? sections[0];
const active = activeSection?.id;
// on switching to another tab, scroll up just enough to unstick the tab bar — bringing the scroll-away
// header back into view — rather than leaving the bar floating mid-content over a freshly mounted panel
const unstickTabBar = () => {
const root = rootRef.current;
if (!root) {
return;
}
const delta = root.getBoundingClientRect().top - headerOffset;
if (delta < 0) {
window.scrollBy({ top: delta });
}
};
return (
<Root
ref={rootRef}
value={active ?? ''}
variant={variant}
onValueChange={(next) => {
// `Root` is keyed by plain strings; map back to the section that owns the value
const section = sections.find(({ id }) => id === next);
if (section) {
onValueChange(section.id);
}
unstickTabBar();
}}
>
{header}
{sections.length > 0 && (
<div className={styles.bar} style={headerOffset ? { top: headerOffset } : undefined}>
<List>
{sections.map((section) => (
<Tab
key={section.id}
icon={section.icon}
label={section.label}
value={section.id}
onClick={() => {
// Base UI's onValueChange doesn't fire when the active tab is re-tapped
if (active !== section.id) {
return;
}
if (onTabReselect) {
onTabReselect(section.id);
} else {
window.scrollTo(0, 0);
}
}}
/>
))}
</List>
{activeSection?.actions && <div className={styles.actions}>{activeSection.actions}</div>}
</div>
)}
{sections.map((section) => (
<Panel key={section.id} value={section.id}>
{section.children}
</Panel>
))}
</Root>
);
};