import type { JSX } from "@solidjs/web"; import { createSignal } from "solid-js"; import { useBaseUiId } from "../base-ui-id"; import { LabelableContext, type LabelableContextValue, } from "./labelable-context"; export interface LabelableProviderProps { /** * Explicit control id to seed the provider with. When omitted, the provider * generates one lazily (only used if a labelable control registers without * supplying its own id). */ controlId?: string | null | undefined; children?: JSX.Element | undefined; } /** * Provides a {@link LabelableContext} to descendants so label components and * the control they label can discover each other. * * Design notes (differences from Base UI React's `LabelableProvider`): * * - Control-id and label-id registries are kept in separate `Map` * registries, each paired with a signal that holds the current computed * view. When a registration changes, we recompute the view once and push * the new value through the signal — no `tick` indirection. * - Registrations arrive from `useLabel` / `useLabelableId` during owned * setup. Both registry signals opt into `ownedWrite` so those writes don't * trip the Solid 2 `SIGNAL_WRITE_IN_OWNED_SCOPE` check (RFC 01). The writes * are pure internal plumbing — no app-visible state depends on their * timing within the owned scope. * - `getLabelIds` returns a fresh frozen array on each change so consumers * that memoize off its reference (e.g. `useAriaLabelledBy` joining with * spaces) see a stable identity between registrations. * - The `controlId` accessor reports the first still-registered control id * (matching React's "first wins, ignore stale" logic). `null` means * "implicit label", `undefined` means "nothing registered yet". */ export function LabelableProvider(props: LabelableProviderProps): JSX.Element { // Default id is generated lazily via `useBaseUiId` so the id slot is only // burned if the consumer actually wants one. const defaultId = useBaseUiId(); const controlRegistrations = new Map(); const labelRegistrations = new Map(); // Seed the control id from props. `null` (explicit implicit) and string // values are kept as-is; `undefined` falls back to the generated default. // // We intentionally read `props.controlId` only once during setup. React's // `LabelableProvider` uses `useState(initialControlId)` which snapshots the // initial value and then lets `registerControlId` drive subsequent updates // — we match that. const initialControlId = props.controlId === undefined ? defaultId : props.controlId; const [controlIdSignal, setControlIdSignal] = createSignal< string | null | undefined >(initialControlId, { ownedWrite: true }); const [labelIdsSignal, setLabelIdsSignal] = createSignal( Object.freeze([] as string[]), // `equals: false` — we always push a fresh frozen array on change, // and we want consumers to re-run even when array contents happen to // match (e.g. a label swap that preserves length + order). { ownedWrite: true, equals: false }, ); function recomputeControlId(prev: string | null | undefined) { // Mirror React: when nothing is registered, fall back to the seeded // initial id (generated default or explicit prop). Otherwise, reuse // `prev` if it's still registered (stable output), else pick the first // registered id in insertion order. if (controlRegistrations.size === 0) { return initialControlId; } let first: string | null | undefined; for (const id of controlRegistrations.values()) { if (prev !== undefined && id === prev) { return prev; } if (first === undefined) { first = id; } } return first; } function registerControlId( source: symbol, id: string | null | undefined, ): void { if (id === undefined) { if (!controlRegistrations.has(source)) return; controlRegistrations.delete(source); } else { const existing = controlRegistrations.get(source); if (existing === id && controlRegistrations.has(source)) return; controlRegistrations.set(source, id); } // `ownedWrite` lets this run during owned setup/cleanup. setControlIdSignal((prev) => recomputeControlId(prev)); } function registerLabelId(source: symbol, id: string | undefined): void { if (id === undefined) { if (!labelRegistrations.has(source)) return; labelRegistrations.delete(source); } else { const existing = labelRegistrations.get(source); if (existing === id) return; labelRegistrations.set(source, id); } // Snapshot the Map's insertion-order values into a fresh frozen array. // `isEqual: false` on the signal means every set fires subscribers — we // want that, because the array identity changes every time a label is // added or removed. setLabelIdsSignal(Object.freeze(Array.from(labelRegistrations.values()))); } // Signals are already accessors — wrapping them in a `createMemo(() => sig())` // just adds a dependency layer that re-emits the same value. Expose the // signal accessors directly. const contextValue: LabelableContextValue = { controlId: controlIdSignal, registerControlId, registerLabelId, getLabelIds: labelIdsSignal, present: true, }; return ( {props.children} ); }