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..7e883a88279 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -539,6 +539,55 @@ 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, but the tab + // stop stays pinned to the first segment (no roving tabindex). + await user.keyboard('[ArrowRight]'); + expect(document.activeElement).toBe(segments[1]); + 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(segments.includes(document.activeElement)).toBe(false); + + // Tabbing back in always returns focus to the first segment. + await user.tab({shift: true}); + expect(document.activeElement).toBe(segments[0]); + }); + 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..87ef80c9cf7 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,6 +79,7 @@ interface HookData { ariaLabelledBy?: string; ariaDescribedBy?: string; focusManager: FocusManager; + keyboardNavigationBehavior: 'arrow' | 'tab'; } export const hookData: WeakMap = new WeakMap(); @@ -145,7 +154,8 @@ export function useDateField( ariaLabelledBy: [labelProps.id, props['aria-labelledby']].filter(Boolean).join(' ') || undefined, ariaDescribedBy: describedBy, - focusManager + focusManager, + 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 e1c1d8d4053..caa44ea9451 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -50,7 +50,8 @@ 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} = + hookData.get(state)!; let textValue = segment.isPlaceholder ? '' : segment.text; let options = useMemo(() => state.dateFormatter.resolvedOptions(), [state.dateFormatter]); @@ -405,7 +406,13 @@ export function useDateSegment( state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : ('numeric' as const), - tabIndex: state.isDisabled ? undefined : 0, + // 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' && segment !== firstSegment + ? -1 + : 0, onFocus, style: segmentStyle, // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.