/**
* 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;
}