From 272b48078bd2a5f5febbef7f0fd232c7c463ea7c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 13 Jul 2026 11:50:41 +1000 Subject: [PATCH 1/3] fix: all keyboard repeat events for navigation --- ...ary-user-event-npm-14.6.1-5da7e1d4e2.patch | 72 +++++++++++++++++++ .../test/actiongroup/ActionGroup.test.js | 22 ++++++ .../test/Calendar.test.js | 10 +++ .../test/ColorArea.test.js | 13 ++++ .../test/ColorWheel.test.js | 13 ++++ .../test/DateField.test.js | 32 +++++++++ .../test/NumberField.test.js | 17 +++++ .../test/RadioGroup.test.js | 19 ++++- .../react-aria-components/test/Select.test.js | 30 ++++++++ .../react-aria-components/test/Slider.test.js | 15 ++++ .../test/TagGroup.test.js | 43 +++++++++++ .../src/actiongroup/useActionGroup.ts | 3 +- .../src/calendar/useCalendarGrid.ts | 58 ++++++++------- packages/react-aria/src/color/useColorArea.ts | 3 +- .../react-aria/src/color/useColorWheel.ts | 3 +- .../react-aria/src/combobox/useComboBox.ts | 17 ++++- .../src/datepicker/useDatePickerGroup.ts | 3 +- .../src/datepicker/useDateSegment.ts | 3 +- .../react-aria/src/radio/useRadioGroup.ts | 3 +- packages/react-aria/src/select/useSelect.ts | 1 + .../react-aria/src/slider/useSliderThumb.ts | 3 +- .../src/spinbutton/useSpinButton.ts | 3 +- packages/react-aria/src/tag/useTag.ts | 3 +- yarn.lock | 4 +- 24 files changed, 354 insertions(+), 39 deletions(-) diff --git a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch index fc4c114ded1..16bf2b3d7d1 100644 --- a/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch +++ b/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch @@ -24,6 +24,42 @@ index 55027cb256f66b808d17280dc01bc55a796a1032..993d5de5a838a711d7ae009344354772 focus.focusElement(dest); if (selection.hasOwnSelection(dest)) { UI.setUISelection(dest, { +diff --git a/dist/cjs/keyboard/index.js b/dist/cjs/keyboard/index.js +index 18f5b4b8eb995fe5ecb352230f71b25e90f2a8a0..e5e17d1d3089aa8ea5c3f55a06aa746eb3a8258a 100644 +--- a/dist/cjs/keyboard/index.js ++++ b/dist/cjs/keyboard/index.js +@@ -19,7 +19,7 @@ async function keyboardAction(instance, { keyDef, releasePrevious, releaseSelf, + } + if (!releasePrevious) { + for(let i = 1; i <= repeat; i++){ +- await system.keyboard.keydown(instance, keyDef); ++ await system.keyboard.keydown(instance, keyDef, i > 1); + if (i < repeat) { + await wait.wait(instance.config); + } +diff --git a/dist/cjs/system/keyboard.js b/dist/cjs/system/keyboard.js +index 3272fd7e642e79a8f67052b4b521355314cbc496..e1b16e9213eb609e7cd2335ecee792c3c19d35b4 100644 +--- a/dist/cjs/system/keyboard.js ++++ b/dist/cjs/system/keyboard.js +@@ -52,7 +52,7 @@ class KeyboardHost { + getPressedKeys() { + return this.pressed.values().map((p)=>p.keyDef); + } +- /** Press a key */ async keydown(instance, keyDef) { ++ /** Press a key */ async keydown(instance, keyDef, repeat = false) { + const key = String(keyDef.key); + const code = String(keyDef.code); + const target = getActiveElement.getActiveElementOrBody(instance.config.document); +@@ -63,7 +63,8 @@ class KeyboardHost { + } + const unprevented = instance.dispatchUIEvent(target, 'keydown', { + key, +- code ++ code, ++ repeat + }); + if (isModifierLock(key) && !this.modifiers[key]) { + this.modifiers[key] = true; diff --git a/dist/cjs/utils/focus/getActiveElement.js b/dist/cjs/utils/focus/getActiveElement.js index d25f3a8ef67e856e43614559f73012899c0b53d7..4ed9ee45565ed438ee9284d8d3043c0bd50463eb 100644 --- a/dist/cjs/utils/focus/getActiveElement.js @@ -37,3 +73,39 @@ index d25f3a8ef67e856e43614559f73012899c0b53d7..4ed9ee45565ed438ee9284d8d3043c0b } else { // Browser does not yield disabled elements as document.activeElement - jsdom does if (isDisabled.isDisabled(activeElement)) { +diff --git a/dist/esm/keyboard/index.js b/dist/esm/keyboard/index.js +index 9c9feb7b82aabeadd8acbd9ec8f4127e302a4634..1456a5cf406382ee08b4bb7184499632bd8d3bd3 100644 +--- a/dist/esm/keyboard/index.js ++++ b/dist/esm/keyboard/index.js +@@ -17,7 +17,7 @@ async function keyboardAction(instance, { keyDef, releasePrevious, releaseSelf, + } + if (!releasePrevious) { + for(let i = 1; i <= repeat; i++){ +- await system.keyboard.keydown(instance, keyDef); ++ await system.keyboard.keydown(instance, keyDef, i > 1); + if (i < repeat) { + await wait(instance.config); + } +diff --git a/dist/esm/system/keyboard.js b/dist/esm/system/keyboard.js +index 43700ca3a873aa6eb1cb4553c0889b685ff90fff..9607372660dcb1737b26abcdc4bc71f35e4d43c3 100644 +--- a/dist/esm/system/keyboard.js ++++ b/dist/esm/system/keyboard.js +@@ -50,7 +50,7 @@ class KeyboardHost { + getPressedKeys() { + return this.pressed.values().map((p)=>p.keyDef); + } +- /** Press a key */ async keydown(instance, keyDef) { ++ /** Press a key */ async keydown(instance, keyDef, repeat = false) { + const key = String(keyDef.key); + const code = String(keyDef.code); + const target = getActiveElementOrBody(instance.config.document); +@@ -61,7 +61,8 @@ class KeyboardHost { + } + const unprevented = instance.dispatchUIEvent(target, 'keydown', { + key, +- code ++ code, ++ repeat + }); + if (isModifierLock(key) && !this.modifiers[key]) { + this.modifiers[key] = true; diff --git a/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js b/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js index ead720c81e0..d7653d3f550 100644 --- a/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js +++ b/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js @@ -226,6 +226,28 @@ describe('ActionGroup', function () { } ); + it('should support repeat keydown events when holding an arrow key', async function () { + // Arrow key navigation is handled by useActionGroup's useKeyboard (allowRepeats). + let tree = render( + + + Click me 1 + Click me 2 + Click me 3 + + + ); + + let buttons = tree.getAllByRole('button'); + await user.tab(); + expect(document.activeElement).toBe(buttons[0]); + + // Hold the key down for two keydown events (the second is a repeat). + await user.keyboard('{ArrowRight>2/}'); + + expect(document.activeElement).toBe(buttons[2]); + }); + it.each` Name | props | disabledKeys | orders ${'middle disabled'} | ${{locale: 'de-DE'}} | ${['1']} | ${[{action: tab, result: () => ['0', '-1', '-1']}, {action: pressArrowRight, result: () => ['-1', '-1', '0']}, {action: pressArrowRight, result: () => ['0', '-1', '-1']}, {action: pressArrowLeft, result: () => ['-1', '-1', '0']}, {action: pressArrowLeft, result: () => ['0', '-1', '-1']}]} diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index 4966edcb9af..2429fc777a2 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -509,6 +509,16 @@ describe('Calendar', () => { expect(calendar.getByLabelText(/selected/)).toBe(day16); }); + it('should support repeat keydown events when holding an arrow key', async () => { + let {getByRole} = renderCalendar({defaultFocusedValue: new CalendarDate(2020, 3, 3)}); + let cell = getByRole('button', {name: 'Tuesday, March 3, 2020'}); + + await user.click(cell); + await user.keyboard('{ArrowRight>2/}'); + + expect(document.activeElement).toHaveAttribute('aria-label', 'Thursday, March 5, 2020'); + }); + it('should not become focused just by setting the focused date', async () => { let DatePicker = () => { let state = useContext(CalendarStateContext); diff --git a/packages/react-aria-components/test/ColorArea.test.js b/packages/react-aria-components/test/ColorArea.test.js index 3e04b6b7515..cc50b55cfac 100644 --- a/packages/react-aria-components/test/ColorArea.test.js +++ b/packages/react-aria-components/test/ColorArea.test.js @@ -175,6 +175,19 @@ describe('ColorArea', () => { expect(wrapper).toHaveClass('disabled'); }); + it('should support repeat keydown events when holding Page Up/Page Down', async () => { + // Page Up/Down adjust the Y (green) channel; the arrow keys are handled by useMove instead. The page step is 17. + let {getAllByRole} = renderColorArea(); + let greenSlider = getAllByRole('slider', {hidden: true})[1]; + + await user.tab(); + await user.keyboard('{PageUp>3/}'); + expect(greenSlider).toHaveValue('51'); + + await user.keyboard('{PageDown>3/}'); + expect(greenSlider).toHaveValue('0'); + }); + it('should support form prop', () => { let {getByRole} = renderColorArea({form: 'test'}); let input = getByRole('slider'); diff --git a/packages/react-aria-components/test/ColorWheel.test.js b/packages/react-aria-components/test/ColorWheel.test.js index c2665d0931a..a4af85c3fc0 100644 --- a/packages/react-aria-components/test/ColorWheel.test.js +++ b/packages/react-aria-components/test/ColorWheel.test.js @@ -173,6 +173,19 @@ describe('ColorWheel', () => { expect(wrapper).toHaveClass('disabled'); }); + it('should support repeat keydown events when holding Page Up/Page Down', async () => { + // Page Up/Down change the hue by the page step (15); the arrow keys are handled by useMove instead. + let {getByRole} = renderColorWheel(); + let slider = getByRole('slider'); + + await user.tab(); + await user.keyboard('{PageUp>3/}'); + expect(slider).toHaveValue('45'); + + await user.keyboard('{PageDown>3/}'); + expect(slider).toHaveValue('0'); + }); + it('should support form prop', () => { let {getByRole} = renderColorWheel({form: 'test'}); let input = getByRole('slider'); diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 43d0e94ff00..d6212bd3163 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -495,6 +495,38 @@ describe('DateField', () => { expect(document.activeElement).toBe(segments[0]); }); + it('should support repeat keydown events when holding backspace across empty segments', async () => { + // Backspace on an empty (placeholder) segment moves focus to the previous segment. + let {getAllByRole} = render( + + + {segment => } + + ); + + let segments = getAllByRole('spinbutton'); + await user.click(segments[2]); + await user.keyboard('{Backspace>2/}'); + + expect(document.activeElement).toBe(segments[0]); + }); + + it('should support repeat keydown events when holding an arrow key to navigate segments', async () => { + // ArrowLeft/ArrowRight move between segments + let {getAllByRole} = render( + + + {segment => } + + ); + + let segments = getAllByRole('spinbutton'); + await user.click(segments[0]); + await user.keyboard('{ArrowRight>2/}'); + + expect(document.activeElement).toBe(segments[2]); + }); + it('should do nothing when pressing enter', async () => { let {getAllByRole} = render( diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 959e08cb62a..0a3363652c6 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -331,6 +331,23 @@ describe('NumberField', () => { expect(onChange).toHaveBeenLastCalledWith(2); }); + it('should support repeat keydown events when holding an arrow key', async () => { + let onChange = jest.fn(); + let {getByRole} = render(); + let input = getByRole('textbox'); + + await user.tab(); + expect(input).toHaveFocus(); + + await user.keyboard('{ArrowUp>3/}'); + expect(input).toHaveValue('1,027'); + expect(onChange).toHaveBeenLastCalledWith(1027); + + await user.keyboard('{ArrowDown>3/}'); + expect(input).toHaveValue('1,024'); + expect(onChange).toHaveBeenLastCalledWith(1024); + }); + it('should not type the grouping characters when useGrouping is false', async () => { let {getByRole} = render(); let input = getByRole('textbox'); diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js index b0eb5a61ab1..596497a88a3 100644 --- a/packages/react-aria-components/test/RadioGroup.test.js +++ b/packages/react-aria-components/test/RadioGroup.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import {Button} from '../src/Button'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {FieldError} from '../src/FieldError'; @@ -380,6 +380,23 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => { expect(label).not.toHaveClass('selected'); }); + it('should support repeat keydown events when holding an arrow key', async () => { + let onChange = jest.fn(); + let {getAllByRole} = renderGroup({onChange}); + let radios = getAllByRole('radio'); + + await user.tab(); + expect(radios[0]).toHaveFocus(); + + // user-event implements its own radio-group arrow navigation, which bypasses our own code + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown'}); + fireEvent.keyDown(document.activeElement, {key: 'ArrowDown', repeat: true}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowDown'}); + + expect(radios[2]).toBeChecked(); + expect(onChange).toHaveBeenLastCalledWith('c'); + }); + it('should support read only state', () => { let className = ({isReadOnly}) => (isReadOnly ? 'readonly' : ''); let {getByRole, getAllByRole} = renderGroup({isReadOnly: true, className}, {className}); diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index df4906037d2..44f81b57a20 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -381,6 +381,36 @@ describe('Select', () => { expect(button).toHaveTextContent('0'); }); + it('should support repeat keydown events when holding an arrow key', async () => { + let onSelectionChange = jest.fn(); + let {getByRole} = render( + + ); + + let button = getByRole('button'); + await user.tab(); + expect(button).toHaveFocus(); + + // Arrow key navigation while closed moves the selection. + await user.keyboard('{ArrowRight>2/}'); + + expect(onSelectionChange).toHaveBeenLastCalledWith(1); + expect(button).toHaveTextContent('1'); + }); + it('should support falsy (0) as a valid default value', async () => { let {getByRole} = render(