Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/react-aria-components/stories/DateField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export default {
validationBehavior: {
control: 'select',
options: ['native', 'aria']
},
keyboardNavigationBehavior: {
control: 'select',
options: ['arrow', 'tab']
}
},
args: {
Expand Down
49 changes: 49 additions & 0 deletions packages/react-aria-components/test/DateField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DateField defaultValue={new CalendarDate(2024, 12, 31)}>
<Label>Birth date</Label>
<DateInput>{segment => <DateSegment segment={segment} />}</DateInput>
</DateField>
);

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(
<DateField keyboardNavigationBehavior="tab" defaultValue={new CalendarDate(2024, 12, 31)}>
<Label>Birth date</Label>
<DateInput>{segment => <DateSegment segment={segment} />}</DateInput>
</DateField>
);

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(
<DateField>
Expand Down
12 changes: 11 additions & 1 deletion packages/react-aria/src/datepicker/useDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export interface AriaDateFieldProps<T extends DateValue>
* [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
Expand Down Expand Up @@ -71,6 +79,7 @@ interface HookData {
ariaLabelledBy?: string;
ariaDescribedBy?: string;
focusManager: FocusManager;
keyboardNavigationBehavior: 'arrow' | 'tab';
}

export const hookData: WeakMap<DateFieldState, HookData> = new WeakMap<DateFieldState, HookData>();
Expand Down Expand Up @@ -145,7 +154,8 @@ export function useDateField<T extends DateValue>(
ariaLabelledBy:
[labelProps.id, props['aria-labelledby']].filter(Boolean).join(' ') || undefined,
ariaDescribedBy: describedBy,
focusManager
focusManager,
keyboardNavigationBehavior: props.keyboardNavigationBehavior || 'arrow'
});

let autoFocusRef = useRef(props.autoFocus);
Expand Down
11 changes: 9 additions & 2 deletions packages/react-aria/src/datepicker/useDateSegment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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.
Expand Down