Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/gridfocus-roving-tabindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[refactor] `useGridFocus` gains `hasRovingTabIndex`, `handleFocus`, and `isRtl`, matching the `useListFocus` API. Calendar now uses `useGridFocus` to own its roving tab stop instead of the separate `useCalendarRovingTabindex` hook.
@cixzhang
74 changes: 52 additions & 22 deletions packages/core/src/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {useGridFocus} from '../hooks';
import {
useCalendarDays,
useCalendarConstraints,
useCalendarRovingTabindex,
type CalendarDay,
} from './hooks';
import {
Expand Down Expand Up @@ -554,14 +553,34 @@ function MonthGrid({
return null;
}, [mode, value]);

const {isTabbable} = useCalendarRovingTabindex({
days,
today,
year,
month: month.month,
isDateDisabled,
selectedDate: selectedDateForTabindex,
});
// Seed the initial roving tab stop for this month. useGridFocus owns the
// live tab stop (see `hasRovingTabIndex` below) — it honors an existing
// `tabindex="0"` and repairs/moves it thereafter — so this only decides
// which day button starts tabbable. Priority: selected date (if visible and
// enabled) > today (if visible and enabled) > first enabled in-month day.
const seedTabbableIso = useMemo((): ISODateString | null => {
if (selectedDateForTabindex) {
const isSelectedInMonth =
selectedDateForTabindex.year === year &&
selectedDateForTabindex.month === month.month;
if (isSelectedInMonth && !isDateDisabled(selectedDateForTabindex)) {
return plainDateToISO(selectedDateForTabindex);
}
}

const isTodayInMonth = today.year === year && today.month === month.month;
if (isTodayInMonth && !isDateDisabled(today)) {
return plainDateToISO(today);
}

for (const day of days) {
if (!day.isOutside && !isDateDisabled(day.date)) {
return day.iso;
}
}

return null;
}, [days, today, year, month.month, isDateDisabled, selectedDateForTabindex]);

// Helper to get the focused date from the currently focused element.
// Reads the machine-readable `data-date` (ISO) attribute rather than parsing
Expand Down Expand Up @@ -628,18 +647,22 @@ function MonthGrid({
// focus (the day button inside the cell). Arrow keys move to the target
// row/column and, if that cell is disabled, continue in the same direction to
// the next enabled cell.
const {gridRef, handleKeyDown: handleGridKeyDown} =
useGridFocus<HTMLDivElement>({
columns: 7,
cellSelector: '[role="gridcell"]',
isCellFocusable: cell =>
cell.querySelector('button:not([disabled])') !== null,
getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
onNavigateBefore: handleNavigatePrevious,
onNavigateAfter: handleNavigateNext,
onPageUp: handlePageUp,
onPageDown: handlePageDown,
});
const {
gridRef,
handleKeyDown: handleGridKeyDown,
handleFocus: handleGridFocus,
} = useGridFocus<HTMLDivElement>({
columns: 7,
cellSelector: '[role="gridcell"]',
isCellFocusable: cell =>
cell.querySelector('button:not([disabled])') !== null,
getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
hasRovingTabIndex: true,
onNavigateBefore: handleNavigatePrevious,
onNavigateAfter: handleNavigateNext,
onPageUp: handlePageUp,
onPageDown: handlePageDown,
});

// Handle pending focus after month navigation
useEffect(() => {
Expand Down Expand Up @@ -719,6 +742,7 @@ function MonthGrid({
role="grid"
aria-label={monthLabel}
onKeyDown={handleGridKeyDown}
onFocus={handleGridFocus}
{...stylex.props(
monthGridStyles.daysGrid,
hasWeekNumbers && monthGridStyles.daysGridWithNumbers,
Expand Down Expand Up @@ -774,7 +798,7 @@ function MonthGrid({
today={today}
hasOutsideDays={hasOutsideDays}
isDisabled={isDateDisabled(day.date)}
isTabbable={isTabbable(day.iso)}
isTabbable={day.iso === seedTabbableIso}
onDayClick={onDayClick}
onDayHover={onDayHover}
/>
Expand Down Expand Up @@ -803,6 +827,11 @@ interface DayCellProps {
today: PlainDate;
hasOutsideDays: boolean;
isDisabled: boolean;
/**
* Whether this day seeds the initial roving tab stop. useGridFocus
* (`hasRovingTabIndex`) owns the live tab stop thereafter — it honors an
* existing `tabindex="0"` and repairs/moves it on navigation and focus.
*/
isTabbable: boolean;
onDayClick: (date: PlainDate) => void;
onDayHover: (date: PlainDate | null) => void;
Expand Down Expand Up @@ -894,6 +923,7 @@ function DayCell({
aria-selected={state.isSelected || state.isInRange || undefined}
aria-disabled={state.effectivelyDisabled || undefined}
disabled={isDisabled}
// Initial roving tab-stop seed; useGridFocus owns it after mount.
tabIndex={isTabbableDay ? 0 : -1}
onClick={() => !state.effectivelyDisabled && onDayClick(date)}
onMouseEnter={() => !state.effectivelyDisabled && onDayHover(date)}
Expand Down
42 changes: 40 additions & 2 deletions packages/core/src/hooks/useGridFocus.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,22 @@ export const docs = {
{
name: 'options.cellSelector',
type: 'string',
description: 'Selector for focusable cells within the grid.',
description: 'Selector for cells within the grid. Should match ALL cell positions in DOM order (including disabled/empty) so grid geometry is preserved.',
default: "'button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'",
required: false,
},
{
name: 'options.isCellFocusable',
type: '(cell: HTMLElement) => boolean',
description: 'Predicate for whether a matched cell can receive focus. Omit to treat every matched cell as focusable.',
required: false,
},
{
name: 'options.getFocusTarget',
type: '(cell: HTMLElement) => HTMLElement | null',
description: 'Resolves the element to focus for a cell, e.g. a button inside a role="gridcell" wrapper. Omit to focus the cell itself.',
required: false,
},
{
name: 'options.onNavigateBefore',
type: '(column: number, offset: number) => void',
Expand All @@ -49,6 +61,20 @@ export const docs = {
description: 'Callback for Page Down key (e.g., navigate to next month in calendars).',
required: false,
},
{
name: 'options.isRtl',
type: 'boolean',
description: 'Swap ArrowLeft/ArrowRight so horizontal navigation follows visual direction in right-to-left contexts.',
default: 'false',
required: false,
},
{
name: 'options.hasRovingTabIndex',
type: 'boolean',
description: 'Own a single roving tab stop across the grid: one focusable cell (its resolved focus target) carries tabindex="0", the rest -1. Stamped/repaired on render and moved with arrow navigation. Attach the returned handleFocus to the container onFocus.',
default: 'false',
required: false,
},
],
returns: [
{
Expand All @@ -61,6 +87,11 @@ export const docs = {
type: '(e: React.KeyboardEvent) => void',
description: 'Key down handler to attach to the grid container.',
},
{
name: 'handleFocus',
type: '(e: React.FocusEvent) => void',
description: 'Focus handler for the grid container. Keeps the roving tab stop in sync when hasRovingTabIndex is enabled; a no-op otherwise, so always safe to attach.',
},
{
name: 'focusCell',
type: '(index: number) => void',
Expand All @@ -83,6 +114,7 @@ export const docs = {
bestPractices: [
{ guidance: true, description: 'Use for calendar date grids: wire onPageUp/onPageDown to month navigation and onNavigateBefore/onNavigateAfter for cross-month arrow key navigation.' },
{ guidance: true, description: 'Attach both gridRef and handleKeyDown to the grid container element.' },
{ guidance: true, description: 'For roving-tabindex grids (e.g. Calendar), set hasRovingTabIndex: true and attach handleFocus to the container onFocus; seed one focus target with tabindex=0 and the hook repairs and moves it.' },
{ guidance: false, description: 'Use for simple linear lists; prefer useListFocus for 1D navigation.' },
],
},
Expand All @@ -99,15 +131,20 @@ export const docsDense = {
paramDescriptions: {
options: 'config for grid focus behavior.',
'options.columns': '# columns in grid. Used for up/down navigation (moves by this many cells).',
'options.cellSelector': 'selector for focusable cells in grid.',
'options.cellSelector': 'selector for grid cells in DOM order (incl. disabled/empty) so geometry is preserved.',
'options.isCellFocusable': 'predicate for whether a matched cell can take focus. Omit = all focusable.',
'options.getFocusTarget': 'resolve element to focus for a cell (e.g. button inside gridcell wrapper). Omit = focus the cell.',
'options.onNavigateBefore': 'callback when navigation would go before first cell. Receives column index + offset (1 for horizontal, columns for vertical).',
'options.onNavigateAfter': 'callback when navigation would go after last cell. Receives column index + offset.',
'options.onPageUp': 'callback for Page Up key (e.g. navigate to previous month in calendars).',
'options.onPageDown': 'callback for Page Down key (e.g. navigate to next month in calendars).',
'options.isRtl': 'swap ArrowLeft/ArrowRight for RTL horizontal navigation. default false.',
'options.hasRovingTabIndex': 'own a single roving tab stop across the grid (one focus target tabindex=0, rest -1); repaired on render + moved with arrows. Attach handleFocus to onFocus. default false.',
},
returnDescriptions: {
gridRef: 'ref to attach to grid container element.',
handleKeyDown: 'key down handler for grid container.',
handleFocus: 'focus handler for grid container; syncs roving tab stop when hasRovingTabIndex on, else no-op.',
focusCell: 'focus specific cell by index (clamped to valid range).',
focusFirst: 'focus first focusable cell in grid.',
focusLast: 'focus last focusable cell in grid.',
Expand All @@ -118,6 +155,7 @@ export const docsDense = {
bestPractices: [
{ guidance: true, description: 'Use for calendar date grids: wire onPageUp/onPageDown to month navigation + onNavigateBefore/onNavigateAfter for cross-month arrow key navigation.' },
{ guidance: true, description: 'Attach both gridRef + handleKeyDown to grid container element.' },
{ guidance: true, description: 'For roving-tabindex grids (e.g. Calendar): hasRovingTabIndex: true + attach handleFocus to onFocus; seed one focus target tabindex=0, hook repairs + moves it.' },
{ guidance: false, description: 'Use for simple linear lists; prefer useListFocus for 1D navigation.' },
],
},
Expand Down
132 changes: 132 additions & 0 deletions packages/core/src/hooks/useGridFocus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file useGridFocus.test.tsx
* @input Uses vitest, @testing-library/react, useGridFocus hook
* @output Unit tests for useGridFocus roving tabindex + RTL navigation
* @position Testing; validates useGridFocus.ts roving-tabindex ownership
*
* SYNC: When useGridFocus.ts changes, update tests to match new behavior
*/

import {describe, it, expect} from 'vitest';
import {render, screen, fireEvent} from '@testing-library/react';
import {useGridFocus} from './useGridFocus';

const NO_DISABLED: number[] = [];

/**
* A 3x3 grid of cells shaped like Calendar: each `role="gridcell"` div wraps a
* focusable `<button>` (the focus target). `seed` marks which button starts
* tabbable (mirrors Calendar seeding the selected/today/first-enabled day).
*/
function Grid({
disabled = NO_DISABLED,
seed = 0,
...opts
}: {
disabled?: number[];
seed?: number;
} & Partial<Parameters<typeof useGridFocus>[0]>) {
const {gridRef, handleKeyDown, handleFocus} = useGridFocus<HTMLDivElement>({
columns: 3,
cellSelector: '[role="gridcell"]',
isCellFocusable: cell =>
cell.querySelector('button:not([disabled])') !== null,
getFocusTarget: cell => cell.querySelector<HTMLElement>('button'),
hasRovingTabIndex: true,
...opts,
});
return (
<div
ref={gridRef}
role="grid"
onKeyDown={handleKeyDown}
onFocus={handleFocus}>
{Array.from({length: 9}, (_, i) => (
<div role="gridcell" key={i}>
<button
type="button"
disabled={disabled.includes(i)}
tabIndex={i === seed ? 0 : -1}
data-testid={`cell-${i}`}>
{i}
</button>
</div>
))}
</div>
);
}

describe('useGridFocus roving tabindex (hasRovingTabIndex)', () => {
it('honors the seeded tab stop and stamps -1 on the rest', () => {
render(<Grid seed={4} />);
expect(screen.getByTestId('cell-4')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
expect(screen.getByTestId('cell-8')).toHaveAttribute('tabindex', '-1');
});

it('repairs to the first focusable cell when no cell is seeded', () => {
render(<Grid seed={-1} />);
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '-1');
});

it('promotes the first ENABLED cell when the seed is disabled', () => {
render(<Grid seed={-1} disabled={[0, 1]} />);
expect(screen.getByTestId('cell-2')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
});

it('ArrowRight moves the tab stop to the next cell', () => {
render(<Grid seed={0} />);
const grid = screen.getByRole('grid');
screen.getByTestId('cell-0').focus();
fireEvent.keyDown(grid, {key: 'ArrowRight'});
expect(screen.getByTestId('cell-1')).toHaveFocus();
expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
});

it('ArrowDown moves the tab stop one row down', () => {
render(<Grid seed={0} />);
const grid = screen.getByRole('grid');
screen.getByTestId('cell-0').focus();
fireEvent.keyDown(grid, {key: 'ArrowDown'});
expect(screen.getByTestId('cell-3')).toHaveFocus();
expect(screen.getByTestId('cell-3')).toHaveAttribute('tabindex', '0');
});

it('handleFocus repairs the stop when the tabbable cell became disabled', () => {
// Seed cell 0 as tabbable, then disable it and fire the container onFocus.
// syncTabStops should promote the first still-focusable cell (cell 1).
const {rerender} = render(<Grid seed={0} />);
const grid = screen.getByRole('grid');
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '0');
rerender(<Grid seed={-1} disabled={[0]} />);
fireEvent.focus(grid);
expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '0');
expect(screen.getByTestId('cell-0')).toHaveAttribute('tabindex', '-1');
});

it('flips ArrowLeft/ArrowRight under RTL', () => {
render(<Grid seed={1} isRtl />);
const grid = screen.getByRole('grid');
screen.getByTestId('cell-1').focus();
// In RTL, ArrowLeft is "forward" (moves to the next cell in DOM order).
fireEvent.keyDown(grid, {key: 'ArrowLeft'});
expect(screen.getByTestId('cell-2')).toHaveFocus();
});

it('does not manage tabindex when hasRovingTabIndex is off', () => {
render(<Grid seed={0} hasRovingTabIndex={false} />);
// The seeded -1/0 values are left untouched (caller owns them), and
// navigation still works without stamping.
const grid = screen.getByRole('grid');
screen.getByTestId('cell-0').focus();
fireEvent.keyDown(grid, {key: 'ArrowRight'});
expect(screen.getByTestId('cell-1')).toHaveFocus();
// cell-1 kept its seeded -1; the hook did not promote it.
expect(screen.getByTestId('cell-1')).toHaveAttribute('tabindex', '-1');
});
});
Loading
Loading