import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, createRenderEffect, omit, Show, untrack } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { mergeRootProps } from "../../internals/merge-root-props"; import { useOpenChangeComplete } from "../../internals/popups/use-open-change-complete.ts"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { type TransitionStatus, useTransitionStatus, } from "../../internals/use-transition-status/use-transition-status.ts"; import { type TabsRootState, type TabsTabValue, useTabsRootContext, } from "../tabs-root-context.ts"; export interface TabsPanelState extends TabsRootState { /** Whether the panel is visually hidden. */ hidden: boolean; /** Transition-status flag (used for enter / exit animations). */ transitionStatus: TransitionStatus; } export interface TabsPanelProps // `value` in our API is meaningful; drop the native `value` shape. extends Omit, "value"> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** The value that identifies the corresponding ``. */ value: TabsTabValue; /** * Whether the panel stays in the DOM when it isn't selected. Necessary for * CSS transitions that animate between panels; off by default to match * React Base UI. * @default false */ keepMounted?: boolean | undefined; } /** * The content panel that renders when its matching `` is active. * Renders a `
`. * * Solid 2 notes: * * - `` wraps the element when `keepMounted` is * `false`. When `keepMounted` is `true`, the element stays in the DOM and * switches between visible / `hidden` based on selection. * - The panel registers its DOM id with the root so the matching tab can * project `aria-controls={panelId}`. Registration happens in a render * effect keyed on `(hidden, keepMounted)` — matches React's * `useIsoLayoutEffect`. */ export function TabsPanel(props: TabsPanelProps): JSX.Element { const root = useTabsRootContext(); // Id overrides are setup-time only (matches field/slider/number-field): // untrack the body read so it doesn't trip `[STRICT_READ_UNTRACKED]`. const id = useBaseUiId( untrack(() => (typeof props.id === "string" ? props.id : undefined)), ); const open = createMemo((): boolean => root.value() === props.value); const keepMounted = (): boolean => props.keepMounted ?? false; const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); const hidden = (): boolean => !mounted(); // Registration effect: whenever the panel would be reachable by a tab // (mounted OR keepMounted), publish its id. Un-register on cleanup so // stale ids don't linger. createRenderEffect( // `props.value` is snapshotted in compute — the apply scope is // strict-read, and a changed value must re-register anyway. () => ({ isHidden: hidden(), isKept: keepMounted(), value: props.value }), ({ isHidden, isKept, value: panelValue }) => { if (isHidden && !isKept) return; root.registerMountedTabPanel(panelValue, id); // Apply scope is unowned — `onCleanup` here would never fire // (`[NO_OWNER_CLEANUP]`). Returning a cleanup function from the // apply hooks into the effect's own teardown instead. return () => { root.unregisterMountedTabPanel(panelValue, id); }; }, ); // Defer unmount until CSS transitions / animations settle. `ref.current` // is populated by `panelRef` below. Matches React's // `useOpenChangeComplete({ open, ref, onComplete: () => setMounted(false) })`: // whenever `open` flips, wait for any in-flight animations on the panel, // then drop `mounted` to `false`. The hook is a no-op when there are no // animations, so the static case still closes synchronously (on the next // microtask). const panelElementRef: { current: HTMLElement | null } = { current: null }; useOpenChangeComplete({ open, ref: panelElementRef, onComplete: () => { if (!open()) { setMounted(false); } }, }); const panelRef = (el: HTMLElement | null): void => { panelElementRef.current = el; }; const userClassStyle = resolveClassStyle(props, () => ({ orientation: root.orientation(), tabActivationDirection: root.tabActivationDirection(), hidden: hidden(), transitionStatus: transitionStatus(), })); const userHtmlProps = omit( props, "as", "class", "style", "attrs", "children", "keepMounted", "value", ) as Record; const internalProps = { id, role: "tabpanel" as const, ref: panelRef, get "aria-labelledby"() { return root.getTabIdByPanelValue(props.value) ?? ""; }, get tabIndex() { return open() ? 0 : -1; }, get hidden() { return hidden() ? true : undefined; }, get "data-orientation"() { return root.orientation(); }, get "data-activation-direction"() { return root.tabActivationDirection(); }, get "data-hidden"(): "" | undefined { return hidden() ? "" : undefined; }, get "data-starting-style"(): "" | undefined { return transitionStatus() === "starting" ? "" : undefined; }, get "data-ending-style"(): "" | undefined { return transitionStatus() === "ending" ? "" : undefined; }, }; const merged = mergeRootProps( mergeRootProps(internalProps, userClassStyle), userHtmlProps, ); const shouldRender = (): boolean => keepMounted() || mounted(); const Tag = dynamic(() => props.as ?? "div"); return ( {props.children} ); }