From b935aef36d01b8bbd6a83bfe7d0d8238993a96a4 Mon Sep 17 00:00:00 2001 From: umarfarooq Date: Fri, 3 Jul 2026 18:01:40 +0500 Subject: [PATCH 1/2] feat(DateField): add keyboardNavigationBehavior prop for single tab stop Adds an opt-in `keyboardNavigationBehavior` prop ('arrow' | 'tab', default 'arrow') to DateField/useDateField, matching the existing prop of the same name/shape on GridList, Tag, and Tree. When set to 'tab', the field becomes a single Tab stop: only one segment (the last-focused one, defaulting to the first) is reachable via Tab, while Left/Right arrow keys continue to move focus between segments as before. This addresses "Tab fatigue" in data-entry heavy forms, where users currently have to press Tab 3-4 times to get through one date field. Implementation: a small per-field TabbableSegmentStore (using the same useSyncExternalStore pattern as useLandmark.ts) tracks which segment currently owns the Tab stop, since DateFieldState has no existing selection/collection state to piggyback on the way GridList does. Closes #9743 --- .../stories/DateField.stories.tsx | 4 ++ .../test/DateField.test.js | 54 +++++++++++++++++++ .../react-aria/src/datepicker/useDateField.ts | 44 ++++++++++++++- .../src/datepicker/useDateSegment.ts | 31 ++++++++++- 4 files changed, 130 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/stories/DateField.stories.tsx b/packages/react-aria-components/stories/DateField.stories.tsx index 50c091a9217..4a8eea1451d 100644 --- a/packages/react-aria-components/stories/DateField.stories.tsx +++ b/packages/react-aria-components/stories/DateField.stories.tsx @@ -58,6 +58,10 @@ export default { validationBehavior: { control: 'select', options: ['native', 'aria'] + }, + keyboardNavigationBehavior: { + control: 'select', + options: ['arrow', 'tab'] } }, args: { diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 43d0e94ff00..05f4094b66e 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -539,6 +539,60 @@ describe('DateField', () => { expect(input).toHaveTextContent('5/30/2000'); }); + it('should have a tab stop on every segment by default (keyboardNavigationBehavior="arrow")', () => { + let {getAllByRole} = render( + + + {segment => } + + ); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).toHaveAttribute('tabIndex', '0'); + } + }); + + it('should support a single tab stop with keyboardNavigationBehavior="tab"', async () => { + let {getAllByRole} = render( + + + {segment => } + + ); + + let segments = getAllByRole('spinbutton'); + expect(segments[0]).toHaveAttribute('tabIndex', '0'); + for (let segment of segments.slice(1)) { + expect(segment).toHaveAttribute('tabIndex', '-1'); + } + + // Tab into the field lands on the single tab stop (the first segment). + await user.tab(); + expect(document.activeElement).toBe(segments[0]); + + // Arrow keys still move focus between segments as usual. + await user.keyboard('[ArrowRight]'); + expect(document.activeElement).toBe(segments[1]); + + // The tab stop follows focus: the segment that was just focused is now + // the only one with tabIndex 0, and the rest (including the previous + // tab stop) are removed from the tab order. + expect(segments[1]).toHaveAttribute('tabIndex', '0'); + expect(segments[0]).toHaveAttribute('tabIndex', '-1'); + for (let segment of segments.slice(2)) { + expect(segment).toHaveAttribute('tabIndex', '-1'); + } + + // Pressing Tab again exits the field entirely instead of moving to the next segment. + await user.tab(); + expect(document.activeElement).not.toBe(segments[2]); + expect(segments.includes(document.activeElement)).toBe(false); + + // Tabbing back in returns focus to the last-focused segment, not the first one. + await user.tab({shift: true}); + expect(document.activeElement).toBe(segments[1]); + }); + it('should reset to placeholders when deleting a partially filled DateField', async () => { let {getAllByRole} = render( diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 00ecdeabcea..01f06c1a1fc 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -41,6 +41,14 @@ export interface AriaDateFieldProps * [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete). */ autoComplete?: string; + /** + * Whether pressing the Tab key moves focus into and out of each individual segment + * (`'arrow'`), or into and out of the field as a single unit, with the Left/Right arrow + * keys used to move between segments (`'tab'`). + * + * @default 'arrow' + */ + keyboardNavigationBehavior?: 'arrow' | 'tab'; } // Allows this hook to also be used with TimeField @@ -71,10 +79,38 @@ interface HookData { ariaLabelledBy?: string; ariaDescribedBy?: string; focusManager: FocusManager; + keyboardNavigationBehavior: 'arrow' | 'tab'; + tabbableSegmentStore: TabbableSegmentStore; } export const hookData: WeakMap = new WeakMap(); +// Tracks which segment is the current Tab stop when keyboardNavigationBehavior is 'tab' +// (roving tabindex). Segments subscribe to this via useSyncExternalStore so that moving +// the Tab stop from one segment to another re-renders exactly the affected segments. +export class TabbableSegmentStore { + private current: string | null = null; + private listeners: Set<() => void> = new Set(); + + getCurrent: () => string | null = () => this.current; + + setCurrent(type: string): void { + if (this.current !== type) { + this.current = type; + for (let listener of this.listeners) { + listener(); + } + } + } + + subscribe: (onChange: () => void) => () => void = onChange => { + this.listeners.add(onChange); + return () => { + this.listeners.delete(onChange); + }; + }; +} + // Private props that we pass from useDatePicker/useDateRangePicker. // Ideally we'd use a Symbol for this, but React doesn't support them: https://github.com/facebook/react/issues/7552 // These need to be stable across server and client module evaluation for SSR hydration. @@ -139,13 +175,19 @@ export function useDateField( ); let groupProps = useDatePickerGroup(state, ref, props[roleSymbol] === 'presentation'); + // The store must be stable for the lifetime of a given state so that segment + // subscriptions aren't torn down and recreated on every render. + let tabbableSegmentStore = useMemo(() => new TabbableSegmentStore(), []); + // Pass labels and other information to segments. hookData.set(state, { ariaLabel: props['aria-label'], ariaLabelledBy: [labelProps.id, props['aria-labelledby']].filter(Boolean).join(' ') || undefined, ariaDescribedBy: describedBy, - focusManager + focusManager, + keyboardNavigationBehavior: props.keyboardNavigationBehavior || 'arrow', + tabbableSegmentStore }); let autoFocusRef = useRef(props.autoFocus); diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index e1c1d8d4053..331846f4736 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -31,6 +31,7 @@ import {useLabels} from '../utils/useLabels'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; import {useSpinButton} from '../spinbutton/useSpinButton'; +import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export interface DateSegmentAria { /** Props for the segment element. */ @@ -50,7 +51,14 @@ export function useDateSegment( let enteredKeys = useRef(''); let {locale, direction} = useLocale(); let displayNames = useDisplayNames(); - let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state)!; + let { + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, + focusManager, + keyboardNavigationBehavior, + tabbableSegmentStore + } = hookData.get(state)!; let textValue = segment.isPlaceholder ? '' : segment.text; let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]); @@ -249,6 +257,7 @@ export function useDateSegment( let onFocus = () => { enteredKeys.current = ''; + tabbableSegmentStore.setCurrent(segment.type); if (ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } @@ -350,6 +359,20 @@ export function useDateSegment( ariaDescribedBy = undefined; } + // When keyboardNavigationBehavior is 'tab', only one segment (a roving Tab stop) should be + // reachable via the Tab key at a time; Left/Right arrow keys (handled in useDatePickerGroup) + // move between segments as usual. Falls back to the first editable segment if the segment + // that last had the Tab stop is no longer present (e.g. the calendar/locale changed). + let tabbableType = useSyncExternalStore( + tabbableSegmentStore.subscribe, + tabbableSegmentStore.getCurrent, + tabbableSegmentStore.getCurrent + ); + let isRovingTabStop = + tabbableType != null && state.segments.some(s => s.isEditable && s.type === tabbableType) + ? segment.type === tabbableType + : segment === firstSegment; + let id = useId(); let isEditable = !state.isDisabled && !state.isReadOnly && segment.isEditable; @@ -405,7 +428,11 @@ export function useDateSegment( state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : ('numeric' as const), - tabIndex: state.isDisabled ? undefined : 0, + tabIndex: state.isDisabled + ? undefined + : keyboardNavigationBehavior === 'tab' && !isRovingTabStop + ? -1 + : 0, onFocus, style: segmentStyle, // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment. From 2bf8e749f0a7ef42a2610b74d89efde4a97172f5 Mon Sep 17 00:00:00 2001 From: umarfarooq Date: Tue, 7 Jul 2026 11:04:04 +0500 Subject: [PATCH 2/2] refactor(DateField): drop roving-tabindex store per review feedback Per @devongovett's review, remove the TabbableSegmentStore/useSyncExternalStore machinery used to track a roving Tab stop. The single tab stop for keyboardNavigationBehavior="tab" is now just the first editable segment: tabIndex is 0 on it and -1 on the rest, with no "last-focused" memory. Arrow keys still move focus between segments as before. The behavioral difference from the original implementation: Tab always exits after the first segment (regardless of which segment was last focused via arrows), and Shift+Tab back into the field always lands on the first segment rather than the last-focused one. Updated the DateField test to match. --- .../test/DateField.test.js | 17 ++++------ .../react-aria/src/datepicker/useDateField.ts | 34 +------------------ .../src/datepicker/useDateSegment.ts | 30 +++------------- 3 files changed, 12 insertions(+), 69 deletions(-) diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 05f4094b66e..7e883a88279 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -570,27 +570,22 @@ describe('DateField', () => { await user.tab(); expect(document.activeElement).toBe(segments[0]); - // Arrow keys still move focus between segments as usual. + // Arrow keys still move focus between segments as usual, but the tab + // stop stays pinned to the first segment (no roving tabindex). await user.keyboard('[ArrowRight]'); expect(document.activeElement).toBe(segments[1]); - - // The tab stop follows focus: the segment that was just focused is now - // the only one with tabIndex 0, and the rest (including the previous - // tab stop) are removed from the tab order. - expect(segments[1]).toHaveAttribute('tabIndex', '0'); - expect(segments[0]).toHaveAttribute('tabIndex', '-1'); - for (let segment of segments.slice(2)) { + expect(segments[0]).toHaveAttribute('tabIndex', '0'); + for (let segment of segments.slice(1)) { expect(segment).toHaveAttribute('tabIndex', '-1'); } // Pressing Tab again exits the field entirely instead of moving to the next segment. await user.tab(); - expect(document.activeElement).not.toBe(segments[2]); expect(segments.includes(document.activeElement)).toBe(false); - // Tabbing back in returns focus to the last-focused segment, not the first one. + // Tabbing back in always returns focus to the first segment. await user.tab({shift: true}); - expect(document.activeElement).toBe(segments[1]); + expect(document.activeElement).toBe(segments[0]); }); it('should reset to placeholders when deleting a partially filled DateField', async () => { diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 01f06c1a1fc..87ef80c9cf7 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -80,37 +80,10 @@ interface HookData { ariaDescribedBy?: string; focusManager: FocusManager; keyboardNavigationBehavior: 'arrow' | 'tab'; - tabbableSegmentStore: TabbableSegmentStore; } export const hookData: WeakMap = new WeakMap(); -// Tracks which segment is the current Tab stop when keyboardNavigationBehavior is 'tab' -// (roving tabindex). Segments subscribe to this via useSyncExternalStore so that moving -// the Tab stop from one segment to another re-renders exactly the affected segments. -export class TabbableSegmentStore { - private current: string | null = null; - private listeners: Set<() => void> = new Set(); - - getCurrent: () => string | null = () => this.current; - - setCurrent(type: string): void { - if (this.current !== type) { - this.current = type; - for (let listener of this.listeners) { - listener(); - } - } - } - - subscribe: (onChange: () => void) => () => void = onChange => { - this.listeners.add(onChange); - return () => { - this.listeners.delete(onChange); - }; - }; -} - // Private props that we pass from useDatePicker/useDateRangePicker. // Ideally we'd use a Symbol for this, but React doesn't support them: https://github.com/facebook/react/issues/7552 // These need to be stable across server and client module evaluation for SSR hydration. @@ -175,10 +148,6 @@ export function useDateField( ); let groupProps = useDatePickerGroup(state, ref, props[roleSymbol] === 'presentation'); - // The store must be stable for the lifetime of a given state so that segment - // subscriptions aren't torn down and recreated on every render. - let tabbableSegmentStore = useMemo(() => new TabbableSegmentStore(), []); - // Pass labels and other information to segments. hookData.set(state, { ariaLabel: props['aria-label'], @@ -186,8 +155,7 @@ export function useDateField( [labelProps.id, props['aria-labelledby']].filter(Boolean).join(' ') || undefined, ariaDescribedBy: describedBy, focusManager, - keyboardNavigationBehavior: props.keyboardNavigationBehavior || 'arrow', - tabbableSegmentStore + keyboardNavigationBehavior: props.keyboardNavigationBehavior || 'arrow' }); let autoFocusRef = useRef(props.autoFocus); diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index 331846f4736..caa44ea9451 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -31,7 +31,6 @@ import {useLabels} from '../utils/useLabels'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; import {useSpinButton} from '../spinbutton/useSpinButton'; -import {useSyncExternalStore} from 'use-sync-external-store/shim/index.js'; export interface DateSegmentAria { /** Props for the segment element. */ @@ -51,14 +50,8 @@ export function useDateSegment( let enteredKeys = useRef(''); let {locale, direction} = useLocale(); let displayNames = useDisplayNames(); - let { - ariaLabel, - ariaLabelledBy, - ariaDescribedBy, - focusManager, - keyboardNavigationBehavior, - tabbableSegmentStore - } = hookData.get(state)!; + let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager, keyboardNavigationBehavior} = + hookData.get(state)!; let textValue = segment.isPlaceholder ? '' : segment.text; let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]); @@ -257,7 +250,6 @@ export function useDateSegment( let onFocus = () => { enteredKeys.current = ''; - tabbableSegmentStore.setCurrent(segment.type); if (ref.current) { scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)}); } @@ -359,20 +351,6 @@ export function useDateSegment( ariaDescribedBy = undefined; } - // When keyboardNavigationBehavior is 'tab', only one segment (a roving Tab stop) should be - // reachable via the Tab key at a time; Left/Right arrow keys (handled in useDatePickerGroup) - // move between segments as usual. Falls back to the first editable segment if the segment - // that last had the Tab stop is no longer present (e.g. the calendar/locale changed). - let tabbableType = useSyncExternalStore( - tabbableSegmentStore.subscribe, - tabbableSegmentStore.getCurrent, - tabbableSegmentStore.getCurrent - ); - let isRovingTabStop = - tabbableType != null && state.segments.some(s => s.isEditable && s.type === tabbableType) - ? segment.type === tabbableType - : segment === firstSegment; - let id = useId(); let isEditable = !state.isDisabled && !state.isReadOnly && segment.isEditable; @@ -428,9 +406,11 @@ export function useDateSegment( state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : ('numeric' as const), + // When keyboardNavigationBehavior is 'tab', only the first segment is a Tab stop; + // Left/Right arrow keys (handled in useDatePickerGroup) still move focus between segments. tabIndex: state.isDisabled ? undefined - : keyboardNavigationBehavior === 'tab' && !isRovingTabStop + : keyboardNavigationBehavior === 'tab' && segment !== firstSegment ? -1 : 0, onFocus,