/** * Stage 4.2 port of Base UI React's `useRole` hook. * * React source: * `base-ui/packages/react/src/floating-ui-react/hooks/useRole.ts` * * Adds base screen-reader props to the reference / floating elements for a * given floating-element `role`. Mostly a pure getter — the only reactive * surface is the `open` / `floatingId` / `floatingElement` / `domReferenceElement` * reads off the root store, which Solid consumers pick up through * `store.useState(...)` when this hook is invoked inside a tracking scope. * * ### React → Solid deviations * * 1. React memoises each prop bag with `useMemo` keyed on `[ariaRole, open, * floatingId, referenceId, role, ...]` so identity is stable across * renders. Solid components don't re-run their bodies, so the getters * are plain object literals — identity is stable for the lifetime of * the call site already. Consumers that need reactivity on `open` / * `floatingId` route through `mergePropsN`'s prop-getter pipeline, * which re-reads these values at event-time. * * 2. Since the bag is built once during setup and re-used, the dynamic * fields (`aria-expanded`, `aria-controls`, `aria-describedby`) are * computed inline by reading `store.useState('open')`. Because every * call to `useRole(...)` is invoked inside a consumer scope that reads * these props through `useInteractions`'s `mergePropsN` at render time, * Solid's fine-grained DOM updates will re-read the getter on every * commit. To keep the ARIA contract 1:1 without over-subscribing, we * expose these as plain lookups on the store — Solid's JSX spread * observes the lookup during render and re-runs it when the store * signal fires. * * 3. `useId` in React → `useBaseUiId` in Solid. */ import { untrack } from "solid-js"; import { useBaseUiId } from "../../base-ui-id"; import { useFloatingParentNodeId } from "../components/floating-tree"; import type { ElementProps, ElementPropsBag, ExtendedUserProps, FloatingContext, FloatingRootContext, } from "../types"; import { getFloatingFocusElement } from "../utils/element"; type AriaRole = | "tooltip" | "dialog" | "alertdialog" | "menu" | "listbox" | "grid" | "tree"; type ComponentRole = "select" | "label" | "combobox"; export interface UseRoleProps { /** * The role of the floating element. * @default 'dialog' */ role?: AriaRole | ComponentRole | undefined; } const componentRoleToAriaRoleMap = new Map< AriaRole | ComponentRole, AriaRole | false >([ ["select", "listbox"], ["combobox", "listbox"], ["label", false], ]); const EMPTY_OBJECT = Object.freeze({}) as Record; /** * Adds base screen reader props to the reference and floating elements for a * given floating element `role`. * @see https://floating-ui.com/docs/useRole */ export function useRole( context: FloatingRootContext | FloatingContext, props: UseRoleProps = {}, ): ElementProps { const store = "rootStore" in context ? context.rootStore : context; // Untrack the body-time `role` read so a parent passing a // signal-derived value via JSX does not fire `[STRICT_READ_UNTRACKED]` // in the strict-read scope set up by `devComponent(...)`. `role` is // not declared as `MaybeAccessor` — it's a snapshot value matching // the React fork's semantics. const { role = "dialog" } = untrack(() => ({ role: props.role })); const defaultReferenceId = useBaseUiId(); const ariaRole = (componentRoleToAriaRoleMap.get(role) ?? role) as | AriaRole | false | undefined; const parentId = useFloatingParentNodeId(); const isNested = parentId != null; // Reactive lookups. Each call reads from the store mirror; when // `mergePropsN` (via `useInteractions`) spreads these into JSX, Solid's // render-effect subscribes to the store signal and re-evaluates on // each change. const getOpen = () => store.state.open; const getFloatingId = () => { const floatingElement = store.state.floatingElement; const defaultFloatingId = store.state.floatingId; return getFloatingFocusElement(floatingElement)?.id ?? defaultFloatingId; }; const getReferenceId = () => { const domReference = store.state.domReferenceElement; return (domReference as HTMLElement | null)?.id || defaultReferenceId; }; const triggerProps: ElementProps["trigger"] = (() => { if (ariaRole === "tooltip" || role === "label") { return EMPTY_OBJECT; } return { "aria-haspopup": ariaRole === "alertdialog" ? "dialog" : ariaRole, "aria-expanded": "false", ...(ariaRole === "listbox" && { role: "combobox" }), ...(ariaRole === "menu" && isNested && { role: "menuitem" }), ...(role === "select" && { "aria-autocomplete": "none" }), ...(role === "combobox" && { "aria-autocomplete": "list" }), }; })(); const reference: ElementProps["reference"] = (() => { if (ariaRole === "tooltip" || role === "label") { // Describedby / labelledby swap — seed with "" when closed (Solid 2's // JSX spread cannot subscribe to keys that start undefined, so we // emit an empty string). return { get [`aria-${role === "label" ? "labelledby" : "describedby"}`]() { return getOpen() ? getFloatingId() : undefined; }, }; } const refResult: ElementPropsBag = { ...triggerProps, get "aria-expanded"() { return getOpen() ? "true" : "false"; }, get "aria-controls"() { return getOpen() ? getFloatingId() : undefined; }, }; // `{...(condition && {get id() {...}})}` LOSES the getter (object // spread invokes getters and copies values), which both breaks // reactivity AND fires `[STRICT_READ_UNTRACKED]` at this hook's // body-time strict-read scope. Define the getter directly so the // signal read happens lazily inside whatever JSX tracking scope // later consumes `reference.id`. if (ariaRole === "menu") { Object.defineProperty(refResult, "id", { enumerable: true, configurable: true, get: () => getReferenceId(), }); } return refResult; })(); const floating: ElementProps["floating"] = (() => { // Build with `Object.defineProperty` so the `id` getter is copied // as a descriptor, not invoked + recopied as a value (the latter // is what `{ ...base }` would do, breaking reactivity AND firing // `[STRICT_READ_UNTRACKED]` at this hook's body-time scope). const base: ElementPropsBag = {}; Object.defineProperty(base, "id", { enumerable: true, configurable: true, get: () => getFloatingId(), }); if (ariaRole) { base.role = ariaRole; } if (ariaRole === "tooltip" || role === "label") { return base; } if (ariaRole === "menu") { Object.defineProperty(base, "aria-labelledby", { enumerable: true, configurable: true, get: () => getReferenceId(), }); return base; } return base; })(); const item: ElementProps["item"] = ({ active, selected, }: ExtendedUserProps) => { const commonProps: Record = { role: "option", }; // Use a getter so `getFloatingId()` is read lazily inside the JSX // tracking scope rather than at this object-build time (which is the // `` body for menu items via `useInteractions`). if (active) { Object.defineProperty(commonProps, "id", { enumerable: true, configurable: true, get: () => `${getFloatingId()}-fui-option`, }); } // For `menu`, we are unable to tell if the item is a `menuitemradio` // or `menuitemcheckbox`. For backwards-compatibility reasons, also // avoid defaulting to `menuitem` as it may overwrite custom role // props. switch (role) { case "select": case "combobox": return { ...commonProps, "aria-selected": selected, }; default: } return {}; }; return { reference, floating, item, trigger: triggerProps }; }