// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useIndicatorFocusRing.tsx * @input A ref to the element wrapping only the indicator * @output Focus handlers that draw the standard ring on the indicator itself * @position Used by controls whose focusable input is visually hidden * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts (exports) * * A checkbox or radio focuses a native `` that is `opacity: 0`, so the * visible focus indicator has to appear on the picture beside it — and that * picture is a themeable indicator, which is what makes "who draws the ring" * awkward: * * - Only the indicator's own element can shape the ring: `outline` follows * that element's `border-radius`. Draw it on a wrapper and the owner has to * hardcode a guess about whatever indicator it hosts (RadioListItem used to * carry `border-radius: 50%` for a circle it did not own). * - But an indicator supplied by a theme is third-party code. If drawing the * ring is its job, a replacement that simply doesn't ships a control with no * visible focus (WCAG 2.4.7) — and that is the DEFAULT outcome: the sample * replacement in our own Storybook destructures `{state, size, isDisabled}` * and drops the rest, which is what a theme author plausibly writes. Nothing * can enforce otherwise; a component ignoring every prop is still assignable * to `IndicatorComponent`, and branding the return type does not work either * (JSX types every expression as `JSX.Element`). * * So the owner draws it, on the indicator's own element, imperatively at focus * time. No cooperation is required and none can be forgotten. * * Two details that are load-bearing, both measured rather than assumed: * * 1. **Inline style, not a class.** React replaces `className` wholesale on * re-render, so an injected class disappears the moment the control changes * state — pressing Space on a focused checkbox lost the ring. React * reconciles `style` per property, so an `outline` set here survives. * 2. **`:focus-visible`, not `:focus`.** The CSS this replaces got * keyboard-vs-pointer for free; an event handler has to ask. Checking the * element keeps the browser's heuristic, including `focus({focusVisible: * true})`, which a hand-rolled modality guess would miss. * * Imperative styling on an interaction event follows `useTableSelection`, * which sets row-selection styles the same way. */ import { useCallback, useMemo } from 'react'; import type { FocusEvent, RefObject } from 'react'; import { FOCUS_OUTLINE_PARTS, FOCUS_OUTLINE_PARTS_NONE } from "../utils/focusOutline.stylex.js"; export interface UseIndicatorFocusRingReturn { /** Spread onto the element that owns the focusable input. */ focusProps: { onFocus: (event: FocusEvent) => void; onBlur: () => void; }; } /** * Draw the standard focus ring on the indicator inside `containerRef`, shaped * by whatever that indicator actually is. * * @param containerRef - Wraps ONLY the indicator, so its single element child * is unambiguously the thing to ring, whatever a theme renders there. * @param isDisabled - Skip the ring; a disabled control is not focusable. * * @example * ``` * const indicatorRef = useRef(null); * const {focusProps} = useIndicatorFocusRing(indicatorRef, isDisabled); *
* * *
* ``` */ export function useIndicatorFocusRing(containerRef: RefObject, isDisabled = false): UseIndicatorFocusRingReturn { const paint = useCallback((on: boolean) => { // `firstElementChild`, not a search: the container holds the indicator // and nothing else, so there is no ambiguity — and no way for a later // sibling to quietly become the target. const indicator = containerRef.current?.firstElementChild; if (indicator instanceof HTMLElement) { Object.assign(indicator.style, on ? FOCUS_OUTLINE_PARTS : FOCUS_OUTLINE_PARTS_NONE); } }, [containerRef]); const onFocus = useCallback((event: FocusEvent) => { if (isDisabled) { return; } const target = event.target; if (target instanceof HTMLElement && !target.matches(':focus-visible')) { return; } paint(true); }, [isDisabled, paint]); const onBlur = useCallback(() => paint(false), [paint]); return useMemo(() => ({ focusProps: { onFocus, onBlur } }), [onFocus, onBlur]); }