import type { JSX } from "@solidjs/web"; /** * Solid port of Base UI React's `FocusGuard` (`packages/react/src/utils/FocusGuard.tsx`). * * Visually-hidden `` sentinel used at popup boundaries to catch * Tab / Shift+Tab and redirect focus. The React source pins * `role="button"` on Safari because VoiceOver only fires the `focus` * event on button-roled elements — other browsers leave `role` unset so * screen-reader announcements don't change. * * Port notes: * - React's `forwardRef` has no Solid equivalent; consumers pass a * callback ref via the `ref` prop (Solid's ref syntax) which is * forwarded to the inner ``. * - `React.useState` → `createSignal`. The Safari `role` is resolved * synchronously in `useIsoLayoutEffect` (matches React's * `useIsoLayoutEffect`). * - Style + nonce handling match the React source 1:1: style comes from * `VISUALLY_HIDDEN_STYLE`, `aria-hidden` is set only when no `role` * is present, and the `data-quoin-focus-guard` marker stays so * downstream focus logic can recognize the sentinel. */ import { createSignal } from "solid-js"; import { isSafari } from "../floating-ui/detect-browser"; import { useIsoLayoutEffect } from "../use-iso-layout-effect"; import { VISUALLY_HIDDEN_STYLE } from "../visually-hidden-style"; export interface FocusGuardProps extends Omit, "role" | "style"> { ref?: ((el: HTMLSpanElement) => void) | HTMLSpanElement | undefined; } /** * @internal */ export function FocusGuard(props: FocusGuardProps): JSX.Element { const [role, setRole] = createSignal<"button" | undefined>(undefined); useIsoLayoutEffect( () => { if (isSafari) { // Unlike other screen readers such as NVDA and JAWS, the // virtual cursor on VoiceOver does trigger the onFocus event, // so we can use the focus trap element. On Safari, only // buttons trigger the onFocus event. setRole("button"); } return undefined; }, () => {}, ); return ( { if (typeof props.ref === "function") props.ref(el); else if (props.ref) (props as { ref: HTMLSpanElement }).ref = el; }} tabindex={0} role={role()} style={{ ...VISUALLY_HIDDEN_STYLE }} aria-hidden={role() ? undefined : "true"} data-quoin-focus-guard="" /> ); }