/** * Contexts used by the composite primitives. * * There are two distinct contexts: * * - `CompositeListContext` — exposes registration helpers + the shared * `elementsRef` / `labelsRef` plain arrays. `useCompositeListItem` reads * from this context; `CompositeList` provides it. * - `CompositeRootContext` — exposes `highlightedIndex()` + its setter so * child items can read/update the focused-item state. This mirrors Base * UI React's `CompositeRootContext`. * * Solid 2 context providers are "context-as-provider" (RFC 02), so a * `{...}` is used at * the call sites — there is no `.Provider`. */ import { type Accessor, createContext, useContext } from "solid-js"; /** * Per-item metadata carried alongside the `Element → metadata` map. `index` * is managed by `CompositeList` — consumers supply their own custom fields. */ export type CompositeMetadata = { index?: number | null | undefined; } & CustomMetadata; /** * A disposer returned by subscription helpers. */ export type Disposer = () => void; /** * The notification payload shared through `CompositeListContext.subscribe`. * Consumers receive the sorted map on every change. */ export type CompositeMapListener = ( map: Map | null>, ) => void; export interface CompositeListContextValue { /** Register a node + its metadata with the list. `source` is a unique, * per-item token that lets the list detect stale entries when a consumer * re-registers with a different node. Token shape is deliberately * `symbol` so consumers can create one per `useCompositeListItem` call. */ register: (source: symbol, node: Element, metadata: Metadata) => void; /** Unregister by `source` token (node may already be detached). */ unregister: (source: symbol) => void; /** Subscribe to sorted-map changes. */ subscribeMapChange: (fn: CompositeMapListener) => Disposer; /** * Plain array of elements ordered by DOM position. This is a plain * JavaScript array — NOT a reactive signal — because the root's keyboard * handler reads it at call time and libraries like floating-ui expect a * mutable `.current`-shaped list. Subscribe to `subscribeMapChange` for * ordering updates. */ elementsRef: Array; /** Parallel labels array, when the caller opted in by passing `labelsRef` * on ``. */ labelsRef?: Array | undefined; } /** * Phantom brand carried by the erased context value. The brand exists only * in the type system — at runtime the field is absent — so there's no cost * beyond TypeScript's assignability check. It prevents arbitrary * `CompositeListContextValue` objects from being mistaken for the erased * shape: only values produced by `eraseCompositeListContext` (or the frozen * no-op default) carry the brand. */ declare const erasedMetadataBrand: unique symbol; /** * Erased storage shape for `CompositeListContext`. `Metadata` is fixed to * `unknown` so the shape is concrete and the context can hold a single * monomorphic type. Providers erase via `eraseCompositeListContext`; * consumers reify via `useCompositeListContext()`. Both ends * preserve the brand so the erasure can't be bypassed with a plain cast. */ export type ErasedCompositeListContextValue = CompositeListContextValue & { readonly [erasedMetadataBrand]: true; }; /** * Erase the `Metadata` type parameter so a concrete * `CompositeListContextValue` can be stored on the shared context. * The cast is confined here — the context's `register`/`subscribeMapChange` * handlers never actually observe values typed against the wrong `Metadata` * because the corresponding `useCompositeListContext()` reader on * the other side re-projects them to the same concrete type before the * caller sees them. */ export function eraseCompositeListContext( value: CompositeListContextValue, ): ErasedCompositeListContextValue { return value as ErasedCompositeListContextValue; } const NOOP_REGISTER = ( _source: symbol, _node: Element, _metadata: unknown, ): void => {}; const NOOP_UNREGISTER = (_source: symbol): void => {}; const NOOP_SUBSCRIBE = (_fn: CompositeMapListener): Disposer => { return () => {}; }; /** * Default value used when `useCompositeListItem` is rendered outside of a * ``. Keeping the shape complete (rather than `undefined`) * lets tier-2 consumers (like `RadioGroup`'s items) call the hook * unconditionally and simply behave as "unregistered" when the wrapper is * absent. */ const NOOP_COMPOSITE_LIST_CONTEXT: ErasedCompositeListContextValue = eraseCompositeListContext({ register: NOOP_REGISTER, unregister: NOOP_UNREGISTER, subscribeMapChange: NOOP_SUBSCRIBE, elementsRef: [], }); /** * The context stores the erased shape. `` passes its concrete * `CompositeListContextValue` through `eraseCompositeListContext`; * consumers pull the branded value back out through * `useCompositeListContext()` which reifies `Metadata` at the call * site. */ export const CompositeListContext = createContext(NOOP_COMPOSITE_LIST_CONTEXT); /** * Read the nearest `CompositeListContext`. Returns the no-op default when * there is no provider, so callers don't have to handle `undefined`. The * single cast here is the only place `Metadata` is re-reified from the * erased storage shape. */ export function useCompositeListContext< Metadata, >(): CompositeListContextValue { return useContext( CompositeListContext, ) as CompositeListContextValue; } export interface CompositeRootContextValue { /** Current highlighted index. */ highlightedIndex: Accessor; /** Setter matching React Base UI's signature exactly. */ onHighlightedIndexChange: ( index: number, shouldScrollIntoView?: boolean, ) => void; /** If `true`, hovering an item moves focus to it. */ highlightItemOnHover: Accessor; /** * Forwards an external keyboard event into the composite's `onKeyDown` * handler. Useful when the triggering element is rendered outside the * composite tree (e.g. menubar with detached triggers). */ relayKeyboardEvent: (event: KeyboardEvent) => void; /** `true` only when provided by a real ``. */ readonly present: boolean; } /** * No-op sentinel for when no `` ancestor exists. Solid 2's * `useContext` throws `ContextNotFoundError` when the default is literal * `undefined` — even inside an owner — so we always supply a sentinel and * let consumers branch on `.present`. */ const NOOP_COMPOSITE_ROOT_CONTEXT: CompositeRootContextValue = { highlightedIndex: () => -1, onHighlightedIndexChange: () => {}, highlightItemOnHover: () => false, relayKeyboardEvent: () => {}, present: false, }; export const CompositeRootContext = createContext( NOOP_COMPOSITE_ROOT_CONTEXT, ); /** * Read the composite root context. Returns `undefined` in the `optional: true` * form when no real `` is an ancestor; otherwise throws. */ export function useCompositeRootContext( optional: true, ): CompositeRootContextValue | undefined; export function useCompositeRootContext( optional?: false, ): CompositeRootContextValue; export function useCompositeRootContext( optional = false, ): CompositeRootContextValue | undefined { const context = useContext(CompositeRootContext); if (!context.present) { if (optional) return undefined; throw new Error( "Base UI: CompositeRootContext is missing. Composite parts must be placed within .", ); } return context; }