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.