import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createSignal, omit } from "solid-js"; import type { CompositeMetadata } from "../../internals/composite/composite-context.ts"; import { createControlledState } from "../../internals/create-controlled-state"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps, Orientation } from "../../internals/types"; import { createTabs } from "../create-tabs.tsx"; import { type TabsRootChangeEventDetails, type TabsRootChangeEventReason, TabsRootContext, type TabsRootContextValue, type TabsRootState, type TabsTabActivationDirection, type TabsTabMetadata, type TabsTabValue, } from "../tabs-root-context.ts"; export type { TabsRootChangeEventDetails, TabsRootChangeEventReason, TabsRootState, TabsTabActivationDirection, TabsTabMetadata, TabsTabValue, }; export type TabsRootOrientation = Orientation; // Locate the DOM elements backing the previous and next selected values in // the tab map. Returns `null` slots when a value isn't registered yet (e.g. a // tab added + selected in the same update). function findTabElements( map: Map | null>, oldValue: TabsTabValue, newValue: TabsTabValue, ): { oldTab: HTMLElement | null; newTab: HTMLElement | null } { let oldTab: HTMLElement | null = null; let newTab: HTMLElement | null = null; for (const [tabElement, tabMetadata] of map.entries()) { if (tabMetadata == null) continue; const tabValue = tabMetadata.value ?? tabMetadata.index; if (oldValue === tabValue) oldTab = tabElement as HTMLElement; if (newValue === tabValue) newTab = tabElement as HTMLElement; if (oldTab && newTab) break; } return { oldTab, newTab }; } // Dynamic-tabs fallback: when an element isn't registered yet, infer // direction from the ordering of comparable values. Matches React's // value-based fallback. function directionFromValues( oldValue: TabsTabValue, newValue: TabsTabValue, orientation: Orientation, atLeastOneFound: boolean, ): TabsTabActivationDirection { const comparable = atLeastOneFound && (typeof oldValue === "number" || typeof oldValue === "string") && typeof oldValue === typeof newValue; if (!comparable) return "none"; // `comparable` guarantees old/new are the same primitive type; the cast // just re-states that for the comparison operator. const after = (newValue as number | string) > (oldValue as number | string); if (orientation === "horizontal") { return after ? "right" : "left"; } return after ? "down" : "up"; } // Direction inferred from the on-screen positions of the two tabs. function directionFromRects( oldRect: DOMRect, newRect: DOMRect, orientation: Orientation, ): TabsTabActivationDirection { if (orientation === "horizontal") { if (newRect.left < oldRect.left) return "left"; if (newRect.left > oldRect.left) return "right"; } else { if (newRect.top < oldRect.top) return "up"; if (newRect.top > oldRect.top) return "down"; } return "none"; } export interface TabsRootProps extends BaseUIComponentProps<"div", TabsRootState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Controlled selected tab. When provided (resolved value not * `undefined` — `null` counts as controlled and means "nothing * selected"), the consumer owns the state — pair with * {@link onValueChange}. When omitted, the tabs manage their own * selection (seeded from {@link defaultValue}). */ value?: MaybeAccessor; /** * Initial selected tab when uncontrolled. Read once at mount. * @default 0 */ defaultValue?: MaybeAccessor; /** * Layout orientation — drives `aria-orientation` on `` and the * direction that arrow-key navigation travels. * @default "horizontal" */ orientation?: MaybeAccessor; /** * Fires when the user activates a different tab. Calling * `eventDetails.cancel()` keeps the tabs' own (uncontrolled) selection * unchanged, mirroring React Base UI; controlled consumers simply * don't update their signal. */ onValueChange?: | ((value: Value, eventDetails: TabsRootChangeEventDetails) => void) | undefined; } /** * Groups a `` + a series of ``s into a single coordinated * tabs widget. Renders a `
` by default. Holds selection state, the DOM * map of tabs (for activation-direction math + `aria-labelledby`), and the * mounted-panel registry (for `aria-controls`). * * Solid 2 notes: * * - Provider lives INSIDE `get children()` of `params.props` so the root's * attribute getters stay owned by this setup scope (matches the radio-group / * progress-root pattern). * - Activation direction is computed lazily inside `onValueChange`; we also * track the previous value so the `data-activation-direction` attribute can * roll over on the transition. React Base UI recomputes it during render to * avoid a one-frame flash — Solid 2's render-effect timing makes that * unnecessary: the attribute projection runs in a `createRenderEffect` after * the state memo settles, so descendants see the correct direction as soon * as `value` flips. */ export function TabsRoot( props: TabsRootProps, ): JSX.Element { // Controlled / uncontrolled value state (Base UI `value` / `defaultValue` // contract): the `value` prop wins while its resolved value is defined // (`null` is a controlled "nothing selected"); otherwise an internal // signal (seeded once from `defaultValue`, else `0` — React's // destructuring default) backs the state. const [resolvedValue, setUncontrolledValue] = createControlledState({ controlled: () => readOption(props.value), default: () => readOption(props.defaultValue), fallback: 0, name: "", state: "value", }); // Compose the headless primitive. It owns the value/orientation accessors // and the change-event emitter; the wrapper keeps DOM-shaped concerns // (tab map, panel-id registry, activation-direction math) since those are // inherently structural. Every derivation downstream (tab aria-selected, // panel hidden state, indicator, activation-direction math) reads // `c.value`, which resolves through the controlled-or-internal accessor. const c = createTabs({ value: resolvedValue, orientation: () => readOption(props.orientation) ?? "horizontal", }); const value = c.value; const orientation = c.orientation; // Tab map owned by this root; ``'s composite-list forwards its // sorted element-map here via `setTabMap` (plumbed through the context). // Stored in a signal so dependent memos (like `selectedTabMetadata`) pick // up new registrations. const [tabMap, setTabMap] = createSignal< Map | null> >(new Map(), { ownedWrite: true }); // Activation direction latched inside onValueChange (called from // unowned event-handler scope, so `ownedWrite` is correct). const [tabActivationDirection, setTabActivationDirection] = createSignal("none", { ownedWrite: true }); const computeActivationDirection = ( oldValue: TabsTabValue, newValue: TabsTabValue, ): TabsTabActivationDirection => { if (oldValue == null || newValue == null) return "none"; const { oldTab, newTab } = findTabElements(tabMap(), oldValue, newValue); if (!oldTab || !newTab) { return directionFromValues( oldValue, newValue, orientation(), Boolean(oldTab || newTab), ); } return directionFromRects( oldTab.getBoundingClientRect(), newTab.getBoundingClientRect(), orientation(), ); }; // Change flow mirrors React's `TabsRoot.onValueChange`: compute the // activation direction and expose it on the details bag, fire the // consumer callback, honor `eventDetails.cancel()`, then latch the // direction + write the uncontrolled state (the write is ignored for // reads while the `value` prop is controlled — the consumer's signal // write is the source of truth). const onValueChange = ( newValue: TabsTabValue, eventDetails: TabsRootChangeEventDetails, ): void => { const currentValue = value(); const direction = computeActivationDirection( currentValue as TabsTabValue, newValue, ); eventDetails.activationDirection = direction; // Internal state is `TabsTabValue` (unknown); the `Value` generic is a // typed convenience for callers, so cast at this boundary. props.onValueChange?.(newValue as Value, eventDetails); if (eventDetails.isCanceled) return; setTabActivationDirection(direction); setUncontrolledValue(newValue); }; // Mounted panels registry — panels call `registerMountedTabPanel(value, id)` // on mount so tabs can wire `aria-controls={panelId}`. Kept as a plain // `Map` with a tick signal driving consumer memos. const mountedPanels = new Map(); const [panelTick, setPanelTick] = createSignal(0, { ownedWrite: true }); const bumpPanels = (): void => { setPanelTick((t) => t + 1); }; const registerMountedTabPanel = ( panelValue: TabsTabValue, panelId: string, ): void => { if (mountedPanels.get(panelValue) === panelId) return; mountedPanels.set(panelValue, panelId); bumpPanels(); }; const unregisterMountedTabPanel = ( panelValue: TabsTabValue, panelId: string, ): void => { if (mountedPanels.get(panelValue) !== panelId) return; mountedPanels.delete(panelValue); bumpPanels(); }; const getTabPanelIdByValue = (tabValue: TabsTabValue): string | undefined => { panelTick(); return mountedPanels.get(tabValue); }; const getTabIdByPanelValue = ( panelValue: TabsTabValue, ): string | undefined => { const map = tabMap(); for (const metadata of map.values()) { if (panelValue === metadata?.value) return metadata?.id; } return undefined; }; const getTabElementBySelectedValue = ( selectedValue: TabsTabValue | undefined, ): HTMLElement | null => { if (selectedValue === undefined) return null; const map = tabMap(); for (const [tabElement, metadata] of map.entries()) { if (metadata == null) continue; const tabValue = metadata.value ?? metadata.index; if (selectedValue === tabValue) return tabElement as HTMLElement; } return null; }; const contextValue: TabsRootContextValue = { value, onValueChange, orientation, tabActivationDirection, getTabElementBySelectedValue, getTabIdByPanelValue, getTabPanelIdByValue, registerMountedTabPanel, unregisterMountedTabPanel, setTabMap, present: true, }; const userClassStyle = resolveClassStyle(props, () => ({ orientation: orientation(), tabActivationDirection: tabActivationDirection(), })); const userHtmlProps = omit( props, "as", "class", "style", "attrs", "children", "defaultValue", "onValueChange", "orientation", "value", ) as Record; const internalProps = { get "data-orientation"() { return orientation(); }, get "data-activation-direction"() { return tabActivationDirection(); }, }; const merged = mergeRootProps( mergeRootProps(internalProps, userClassStyle), userHtmlProps, ); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }