shadimage

Tabs

Tabs navigation primitives for Tile UI.

packages/react/src/components/tabs/tabs.tsx

import React, { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { getTabsListVariantKey, getTabsState, observeTabsIndicator, tabsStyleKeys } from '../lib/core';
import type { TabsBaseProps, TabsListBaseProps, TabsTriggerBaseProps, TabsContentBaseProps } from '../lib/core';
import styles from './tabs.module.scss';

interface TabsContextValue {
	value: string;
	orientation: 'horizontal' | 'vertical';
	baseId: string;
	select: (value: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);
const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;

function useTabsContext(): TabsContextValue {
	const context = useContext(TabsContext);
	if (!context) {
		throw new Error('Tabs sub-components must be used within <Tabs>.');
	}
	return context;
}

export interface TabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'value' | 'defaultValue'>, TabsBaseProps {}

const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(({ className = '', value, defaultValue = '', orientation = 'horizontal', onValueChange, children, ...props }, ref) => {
	const [internalValue, setInternalValue] = useState(defaultValue);
	const currentValue = value !== undefined ? value : internalValue;
	const baseId = useId();

	function select(next: string) {
		if (value === undefined) {
			setInternalValue(next);
		}
		onValueChange?.(next);
	}

	return (
		<TabsContext.Provider value={{ value: currentValue, orientation, baseId, select }}>
			<div ref={ref} data-orientation={orientation} className={`${styles[tabsStyleKeys.root]} ${className}`} {...props}>
				{children}
			</div>
		</TabsContext.Provider>
	);
});
Tabs.displayName = 'Tabs';

export interface TabsListProps extends React.HTMLAttributes<HTMLDivElement>, TabsListBaseProps {}

const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(({ className = '', variant = 'default', children, onKeyDown, ...props }, ref) => {
	const context = useTabsContext();
	const variantKey = getTabsListVariantKey(variant);
	const isVertical = context.orientation === 'vertical';
	const listRef = useRef<HTMLDivElement>(null);
	const indicatorRef = useRef<HTMLSpanElement>(null);
	const measurement = useRef<ReturnType<typeof observeTabsIndicator> | null>(null);

	useBrowserLayoutEffect(() => {
		if (!listRef.current || !indicatorRef.current) return;
		const observer = observeTabsIndicator(listRef.current, indicatorRef.current);
		measurement.current = observer;
		return () => {
			observer.disconnect();
			measurement.current = null;
		};
	}, []);
	useBrowserLayoutEffect(() => measurement.current?.update());

	function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
		onKeyDown?.(event);

		const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
		const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
		const isNext = event.key === nextKey;
		const isPrev = event.key === prevKey;
		const isHome = event.key === 'Home';
		const isEnd = event.key === 'End';

		if (!isNext && !isPrev && !isHome && !isEnd) {
			return;
		}

		const triggers = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button[role="tab"]')).filter((trigger) => !trigger.disabled);

		if (triggers.length === 0) {
			return;
		}

		const currentIndex = triggers.indexOf(document.activeElement as HTMLButtonElement);
		let nextIndex: number;
		if (isHome) {
			nextIndex = 0;
		} else if (isEnd) {
			nextIndex = triggers.length - 1;
		} else {
			const direction = isNext ? 1 : -1;
			nextIndex = (currentIndex + direction + triggers.length) % triggers.length;
		}

		const nextTrigger = triggers[nextIndex];
		nextTrigger?.focus();

		// 自动激活:方向键移动焦点时同步选中对应标签页。
		const nextValue = nextTrigger?.getAttribute('data-value');
		if (nextValue !== null && nextValue !== undefined) {
			context.select(nextValue);
		}

		event.preventDefault();
	}

	return (
		<div
			ref={(element) => {
				listRef.current = element;
				if (typeof ref === 'function') return ref(element);
				if (ref) ref.current = element;
			}}
			role="tablist"
			data-orientation={context.orientation}
			data-variant={variant}
			onKeyDown={handleKeyDown}
			className={`${styles[tabsStyleKeys.list]} ${styles[variantKey]} ${className}`}
			{...props}>
			{children}
			<span ref={indicatorRef} aria-hidden="true" data-slot="tabs-indicator" data-state="hidden" className={styles.indicator} />
		</div>
	);
});
TabsList.displayName = 'TabsList';

export interface TabsTriggerProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'value'>, TabsTriggerBaseProps {
	value: string;
}

const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(({ className = '', value, disabled = false, children, onClick, ...props }, ref) => {
	const context = useTabsContext();
	const active = context.value === value;
	const state = getTabsState(active);
	const triggerId = `${context.baseId}-trigger-${value}`;
	const contentId = `${context.baseId}-content-${value}`;

	function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
		context.select(value);
		onClick?.(event);
	}

	return (
		<button
			ref={ref}
			type="button"
			id={triggerId}
			role="tab"
			aria-selected={active}
			aria-controls={contentId}
			data-value={value}
			tabIndex={active ? 0 : -1}
			disabled={disabled}
			data-state={state}
			data-orientation={context.orientation}
			className={`${styles[tabsStyleKeys.trigger]} ${className}`}
			onClick={handleClick}
			{...props}>
			{children}
		</button>
	);
});
TabsTrigger.displayName = 'TabsTrigger';

export interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement>, TabsContentBaseProps {
	value: string;
}

const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(({ className = '', value, children, ...props }, ref) => {
	const context = useTabsContext();
	const active = context.value === value;
	const state = getTabsState(active);
	const triggerId = `${context.baseId}-trigger-${value}`;
	const contentId = `${context.baseId}-content-${value}`;

	return (
		<div
			{...props}
			ref={ref}
			id={contentId}
			role="tabpanel"
			aria-labelledby={triggerId}
			data-state={state}
			data-orientation={context.orientation}
			hidden={!active}
			className={`${styles[tabsStyleKeys.content]} ${className}`}>
			{children}
		</div>
	);
});
TabsContent.displayName = 'TabsContent';

export { Tabs, TabsList, TabsTrigger, TabsContent };
export default Tabs;

No selection tags yet — empty means no evidence, not lack of support.