// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file useGridFocus.ts * @input Uses React useCallback, useRef, useIsomorphicLayoutEffect, * isRtlElement * @output Exports useGridFocus hook for grid keyboard navigation * @position Core hook; used by Calendar for date grid navigation. Auto-detects * RTL from the container's computed direction so horizontal arrow keys * follow visual direction (WCAG 1.3.2). * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts * - /packages/core/src/hooks/useGridFocus.test.tsx */ import { useCallback, useRef } from 'react'; import { isRtlElement } from "./isRtlElement.js"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js"; /** * Configuration for grid focus behavior */ export interface UseGridFocusOptions { /** * Number of columns in the grid. * Used for up/down navigation (moves by this many cells). */ columns: number; /** * Selector for cells within the grid. This should match ALL grid cell * positions in DOM order (including disabled/empty ones) so the hook can * preserve the true grid geometry when computing rows and columns. * * Use {@link UseGridFocusOptions.isCellFocusable} to distinguish cells that * can receive focus from those that cannot (disabled or empty). Navigation * moves to a target row/column and, if that cell is not focusable, continues * in the same direction to the next focusable cell — the geometry is never * collapsed to only-focusable cells. * * @default 'button:not([disabled]), [tabindex]:not([tabindex="-1"])' */ cellSelector?: string; /** * Predicate determining whether a cell matched by {@link cellSelector} can * receive focus. When omitted, every matched cell is considered focusable * (backwards-compatible behavior). * * @param cell A cell element matched by `cellSelector`. */ isCellFocusable?: (cell: HTMLElement) => boolean; /** * Resolves the element to focus for a given cell. Useful when the cell is a * wrapper element (e.g. a `role="gridcell"` div) whose focusable content is a * descendant (e.g. a `)} * * ``` * * Roving-tabindex grid (e.g. a date picker where the cell is a wrapper around * a focusable button): * * @example * ``` * const {gridRef, handleKeyDown, handleFocus} = useGridFocus({ * columns: 7, * cellSelector: '[role="gridcell"]', * isCellFocusable: cell => cell.querySelector('button:not([disabled])') != null, * getFocusTarget: cell => cell.querySelector('button'), * hasRovingTabIndex: true, * }); * *
* {cells} *
* ``` */ export function useGridFocus(options: UseGridFocusOptions): UseGridFocusReturn { const { columns, cellSelector = 'button:not([disabled]), [tabindex]:not([tabindex="-1"])', isCellFocusable, getFocusTarget, onNavigateBefore, onNavigateAfter, onPageUp, onPageDown, hasRovingTabIndex = false } = options; const gridRef = useRef(null); /** * Get all cells in the grid, in DOM order. This includes disabled and empty * cells so the true grid geometry is preserved. */ const getCells = useCallback((): HTMLElement[] => { if (!gridRef.current) { return []; } return Array.from(gridRef.current.querySelectorAll(cellSelector)); }, [cellSelector]); /** * Whether a cell can receive focus. */ const cellFocusable = useCallback((cell: HTMLElement | undefined): boolean => { if (!cell) { return false; } return isCellFocusable ? isCellFocusable(cell) : true; }, [isCellFocusable]); /** * Resolve the element that should actually receive focus for a cell. */ const resolveFocusTarget = useCallback((cell: HTMLElement | undefined): HTMLElement | null => { if (!cell) { return null; } return getFocusTarget ? getFocusTarget(cell) : cell; }, [getFocusTarget]); // --- Roving tabindex ownership (opt-in via `hasRovingTabIndex`) ------------- /** * Set `tabindex` on an element, but only when it differs (avoids redundant * DOM writes). Uses setAttribute so the value reflects even for elements * (like `