import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createSignal, omit, onCleanup } from "solid-js"; import { useDirection } from "../../direction-provider/direction-provider-context.ts"; import { CompositeRootContext, type CompositeRootContextValue, } from "../../internals/composite/composite-context.ts"; import { CompositeList } from "../../internals/composite/composite-list.tsx"; import { useCompositeRoot } from "../../internals/composite/composite-root.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { TabsListContext, type TabsListContextValue, } from "../tabs-list-context.ts"; import { type TabsRootChangeEventDetails, type TabsRootState, type TabsTabMetadata, type TabsTabValue, useTabsRootContext, } from "../tabs-root-context.ts"; export interface TabsListState extends TabsRootState {} export interface TabsListProps extends BaseUIComponentProps<"div", TabsListState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Whether to activate the tab the user moves focus to (via arrow keys or * programmatic focus) without requiring a click. When `false`, Enter / * Space is still required to activate the focused tab. * @default false */ activateOnFocus?: boolean | undefined; /** * Whether arrow-key navigation wraps from the last tab to the first and * vice versa. * @default true */ loopFocus?: boolean | undefined; } /** * Groups the `` buttons + (optionally) the `` into * a single keyboard-navigable widget. Renders a `
`. * * Composite-focus wiring: arrow keys move the roving focus between tabs, * Home/End jump to the first/last non-disabled tab, and Enter/Space activates * the focused tab (via ``'s `useButton`). When `activateOnFocus` is * `true`, focus alone activates the tab — child tabs read this through the * list context + apply it inside their own `onFocus` handler. */ export function TabsList(props: TabsListProps): JSX.Element { const rootContext = useTabsRootContext(); const direction = useDirection(); const activateOnFocus = (): boolean => props.activateOnFocus ?? false; const loopFocus = (): boolean => props.loopFocus ?? true; const [tabsListElement, setTabsListElement] = createSignal(null, { ownedWrite: true }); // Resize observer bookkeeping. Child tabs register their element via // `registerTabResizeObserverElement`; the indicator rerenders on every // observed size change (through its own subscription — tier-3b MVP // doesn't ship the full observer, so this is exposed as a register/unregister // pair for forward compatibility + to satisfy tab signatures). const tabResizeObserverElements = new Set(); let resizeObserver: ResizeObserver | null = null; const ensureObserver = (): ResizeObserver | null => { if (resizeObserver) return resizeObserver; if (typeof ResizeObserver === "undefined") return null; resizeObserver = new ResizeObserver(() => { // Intentionally empty — indicator measurement is recomputed in its // own render effect from the live DOM, and the observer's role is // to trigger a re-measure. A future iteration will expose a // listener API here. }); return resizeObserver; }; const registerTabResizeObserverElement = ( element: HTMLElement, ): (() => void) => { tabResizeObserverElements.add(element); const observer = ensureObserver(); observer?.observe(element); return () => { tabResizeObserverElements.delete(element); observer?.unobserve(element); }; }; onCleanup(() => { resizeObserver?.disconnect(); resizeObserver = null; tabResizeObserverElements.clear(); }); // Observe the list element itself (for indicator-on-resize hooks). const attachListObserver = (el: HTMLElement | null): void => { if (!el) return; ensureObserver()?.observe(el); }; const onTabActivation = ( newValue: TabsTabValue, eventDetails: TabsRootChangeEventDetails, ): void => { if (newValue !== rootContext.value()) { rootContext.onValueChange(newValue, eventDetails); } }; const composite = useCompositeRoot({ get orientation() { return rootContext.orientation(); }, get loopFocus() { return loopFocus(); }, direction, enableHomeAndEndKeys: true, }); const compositeRootContext: CompositeRootContextValue = { highlightedIndex: composite.highlightedIndex, onHighlightedIndexChange: composite.onHighlightedIndexChange, highlightItemOnHover: () => false, relayKeyboardEvent: composite.relayKeyboardEvent, present: true, }; const tabsListContextValue: TabsListContextValue = { activateOnFocus, highlightedTabIndex: composite.highlightedIndex, registerTabResizeObserverElement, onTabActivation, setHighlightedTabIndex: composite.onHighlightedIndexChange, tabsListElement, present: true, }; const userClassStyle = resolveClassStyle(props, () => ({ orientation: rootContext.orientation(), tabActivationDirection: rootContext.tabActivationDirection(), })); const userHtmlProps = omit( props, "as", "class", "style", "attrs", "activateOnFocus", "children", "loopFocus", ) as Record; const internalProps = { ref: (el: HTMLElement | null): void => { setTabsListElement(el); composite.rootRef(el); attachListObserver(el); }, role: "tablist" as const, get "aria-orientation"() { return rootContext.orientation() === "vertical" ? "vertical" : undefined; }, get "data-orientation"() { return rootContext.orientation(); }, get "data-activation-direction"() { return rootContext.tabActivationDirection(); }, }; const merged = mergeRootProps( mergeRootProps( mergeRootProps(internalProps, composite.props), userClassStyle, ), userHtmlProps, ); const Tag = dynamic(() => props.as ?? "div"); return ( elementsRef={composite.elementsRef} onMapChange={(map) => { rootContext.setTabMap(map); composite.onMapChange(map); }} > {props.children} ); }