diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js
index f1a16e5f30d..9b1453b80cc 100644
--- a/packages/react-aria-components/test/DateField.test.js
+++ b/packages/react-aria-components/test/DateField.test.js
@@ -612,6 +612,105 @@ describe('DateField', () => {
expect(segements[2]).toHaveTextContent('yyyy');
});
+ it.each`
+ keys | expected
+ ${'532026'} | ${['5', '3', '2026']}
+ ${'13'} | ${['1', '3', 'yyyy']}
+ ${'1132'} | ${['11', '3', '2']}
+ `(
+ 'moves an overflowing digit to the next segment when typing $keys',
+ async ({keys, expected}) => {
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[0]);
+ await user.keyboard(keys);
+ expect(segments.map(s => s.textContent)).toEqual(expected);
+ }
+ );
+
+ it('emits the full date after an overflowing digit moves to the next segment', async () => {
+ let onChange = jest.fn();
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ await user.click(getAllByRole('spinbutton')[0]);
+ await user.keyboard('532026');
+ expect(onChange).toHaveBeenLastCalledWith(new CalendarDate(2026, 5, 3));
+ });
+
+ it('moves an overflowing digit to the next segment when editing an existing date', async () => {
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[1]);
+ await user.keyboard('32024');
+ expect(segments.map(s => s.textContent)).toEqual(['2', '3', '2024']);
+ expect(segments[2]).toHaveFocus();
+ });
+
+ it('replaces a pre-filled segment value with the first typed digit', async () => {
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[1]);
+ await user.keyboard('2');
+ expect(segments.map(s => s.textContent)).toEqual(['5', '2', '2026']);
+ expect(segments[1]).toHaveFocus();
+ });
+
+ it('does not move to the next segment when the entered digits fit', async () => {
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[1]);
+ await user.keyboard('31');
+ expect(segments[1]).toHaveTextContent('31');
+ expect(segments[2]).toHaveFocus();
+ expect(segments[2]).toHaveTextContent('yyyy');
+ });
+
+ it('replaces the value when the last segment overflows', async () => {
+ let {getAllByRole} = render(
+
+
+
+ {segment => }
+
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[0]);
+ await user.keyboard('2026532');
+ expect(segments.map(s => s.textContent)).toEqual(['2026', '5', '2']);
+ expect(segments[2]).toHaveFocus();
+ });
+
// Regression test for #10259: in Firefox a stale selection anchor can remain inside a segment
// after focus moves away, and the selectionchange handler would collapse onto it, stealing focus.
it('does not collapse the selection onto a segment while another element is focused', () => {
diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js
index bfaee202336..f0fdaec44e7 100644
--- a/packages/react-aria-components/test/DateRangePicker.test.js
+++ b/packages/react-aria-components/test/DateRangePicker.test.js
@@ -20,6 +20,7 @@ import {Dialog} from '../src/Dialog';
import {FieldError} from '../src/FieldError';
import {Group} from '../src/Group';
import {Heading} from '../src/Heading';
+import {I18nProvider} from 'react-aria/I18nProvider';
import {Label} from '../src/Label';
import {Popover} from '../src/Popover';
import React from 'react';
@@ -105,6 +106,20 @@ describe('DateRangePicker', () => {
expect(getByRole('grid')).toHaveClass('react-aria-CalendarGrid');
});
+ it('moves an overflowing digit from the start field into the end field', async () => {
+ let {getAllByRole} = render(
+
+
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[0]);
+ await user.keyboard('2026532');
+ expect(segments.map(s => s.textContent)).toEqual(['2026', '5', '3', '2', '月', '日']);
+ expect(segments[3]).toHaveFocus();
+ });
+
it('should support the slot prop', () => {
let {getByRole} = render(
diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js
index f32948aec5a..acafee78261 100644
--- a/packages/react-aria-components/test/TimeField.test.js
+++ b/packages/react-aria-components/test/TimeField.test.js
@@ -210,6 +210,24 @@ describe('TimeField', () => {
expect(group.closest('.react-aria-TimeField')).toHaveAttribute('data-required');
});
+ it('moves an overflowing hour digit to the minute segment', async () => {
+ let onChange = jest.fn();
+ let {getAllByRole} = render(
+
+
+ {segment => }
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ await user.click(segments[0]);
+ await user.keyboard('135');
+ expect(segments[0]).toHaveTextContent('1');
+ expect(segments[1]).toHaveTextContent('35');
+ await user.keyboard('p');
+ expect(onChange).toHaveBeenLastCalledWith(new Time(13, 35));
+ });
+
it('should support form value', () => {
render(
diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts
index 09f75359211..8a02511e474 100644
--- a/packages/react-aria/src/datepicker/useDateSegment.ts
+++ b/packages/react-aria/src/datepicker/useDateSegment.ts
@@ -12,6 +12,7 @@
import {CalendarDate, toCalendar} from '@internationalized/date';
import {DateFieldState, DateSegment} from 'react-stately/useDateFieldState';
+import {FocusManager} from '../focus/FocusScope';
import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions';
import {getScrollParent} from '../utils/getScrollParent';
import {hookData} from './useDateField';
@@ -37,6 +38,11 @@ export interface DateSegmentAria {
segmentProps: React.HTMLAttributes;
}
+// A digit that overflowed the previous segment (e.g. the "2" in "32" for a day), to be entered
+// into the next segment when it receives focus. Set and consumed synchronously within focusNext().
+// Keyed by focus manager so it also crosses between the start and end fields of a date range picker.
+const pendingKeys = new WeakMap();
+
/**
* Provides the behavior and accessibility implementation for a segment in a date field.
* A date segment displays an individual unit of a date and time, and allows users to edit
@@ -226,6 +232,17 @@ export function useDateSegment(
let numberValue = parser.parse(newValue);
let segmentValue = numberValue;
if (segment.maxValue !== undefined && numberValue > segment.maxValue) {
+ // The previously entered digits are a complete value on their own, so keep them
+ // and enter the new digit into the next segment instead (e.g. typing "32" in a day).
+ if (enteredKeys.current !== '') {
+ enteredKeys.current = '';
+ pendingKeys.set(focusManager, key);
+ let next = focusManager.focusNext();
+ pendingKeys.delete(focusManager);
+ if (next) {
+ break;
+ }
+ }
segmentValue = parser.parse(key);
}
@@ -259,6 +276,12 @@ export function useDateSegment(
// Collapse selection to start or Chrome won't fire input events.
let selection = window.getSelection();
selection?.collapse(ref.current);
+
+ let pendingKey = pendingKeys.get(focusManager);
+ if (pendingKey != null) {
+ pendingKeys.delete(focusManager);
+ onInput(pendingKey);
+ }
};
let documentRef = useRef(typeof document !== 'undefined' ? document : null);