From 6cbf39f446b004aa5122e051a3c0852390c76db5 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 12 Jun 2026 15:04:20 -0700 Subject: [PATCH] Revert "feat: Keyboard shortcut handler (#9929)" This reverts commit 9e1b07014067f824a50cae586a4532a9d0b9f841. --- .../src/actionbar/ActionBar.tsx | 5 +- packages/@react-spectrum/s2/src/ActionBar.tsx | 7 +- .../test/ListBox.test.js | 37 -- .../src/actiongroup/useActionGroup.ts | 40 +- .../src/calendar/useCalendarGrid.ts | 84 ++-- packages/react-aria/src/color/useColorArea.ts | 88 ++-- .../react-aria/src/color/useColorWheel.ts | 30 +- .../react-aria/src/combobox/useComboBox.ts | 77 ++- .../react-aria/src/datepicker/useDateField.ts | 13 +- .../src/datepicker/useDatePickerGroup.ts | 64 ++- .../src/datepicker/useDateSegment.ts | 39 +- .../src/interactions/createEventHandler.ts | 8 +- .../createKeyboardShortcutHandler.ts | 217 --------- .../src/interactions/useKeyboard.ts | 59 +-- packages/react-aria/src/menu/useMenuItem.ts | 58 ++- .../react-aria/src/menu/useMenuTrigger.ts | 90 ++-- .../react-aria/src/menu/useSubmenuTrigger.ts | 91 ++-- .../src/numberfield/useNumberField.ts | 29 +- .../react-aria/src/overlays/useOverlay.ts | 19 +- .../react-aria/src/radio/useRadioGroup.ts | 57 +-- .../src/searchfield/useSearchField.ts | 63 +-- packages/react-aria/src/select/useSelect.ts | 39 +- .../src/selection/useSelectableCollection.ts | 457 ++++++++---------- .../react-aria/src/slider/useSliderThumb.ts | 60 +-- .../src/spinbutton/useSpinButton.ts | 87 ++-- .../src/steplist/useStepListItem.ts | 12 + .../src/table/useTableColumnResize.ts | 31 +- packages/react-aria/src/tag/useTag.ts | 33 +- .../useSearchAutocomplete.test.js | 4 +- .../test/combobox/useComboBox.test.js | 48 +- .../test/interactions/useKeyboard.test.js | 278 ----------- .../test/searchfield/useSearchField.test.js | 7 +- 32 files changed, 823 insertions(+), 1408 deletions(-) delete mode 100644 packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts diff --git a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx index c59b9c93a41..016a8c620ec 100644 --- a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx +++ b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx @@ -110,8 +110,9 @@ function ActionBarInner(props: ActionBarInnerProps, ref: Ref { + onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); onClearSelection(); } } diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index e7b65502b36..323cab27972 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -167,9 +167,12 @@ const ActionBarInner = forwardRef(function ActionBarInner( }); let {keyboardProps} = useKeyboard({ - shortcuts: { - Escape: () => { + onKeyDown(e) { + if (e.key === 'Escape') { + e.preventDefault(); onClearSelection?.(); + } else { + e.continuePropagation(); } } }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index f851badd821..38a125105df 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2396,40 +2396,3 @@ describe('ListBox', () => { }); } }); - -describe('keyboard modifier keys', () => { - let user; - let platformMock; - beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); - // selectionMode: 'none', 'single', 'multiple' - // selectionBehavior: 'toggle', 'replace' - // platform: 'mac', 'windows' - - // modifier key: 'alt', 'ctrl', 'meta', 'shift' - // key: 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'home', 'end', 'page-up', 'page-down', 'enter', 'space', 'tab' - // expected behavior: 'navigate', 'select', 'toggle', 'replace' - describe('mac', () => { - beforeAll(() => { - platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); - }); - afterAll(() => { - platformMock.mockRestore(); - }); - it('should not navigate when using unsupported modifier keys', async () => { - let {getByRole} = renderListbox({selectionMode: 'none'}); - await user.tab(); - let listbox = getByRole('listbox'); - let options = within(listbox).getAllByRole('option'); - await user.keyboard('{ArrowDown}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Meta>}{ArrowDown}{/Meta}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Meta>}{ArrowUp}{/Meta}'); - expect(document.activeElement).toBe(options[1]); - await user.keyboard('{Control>}{Home}{/Control}'); - expect(document.activeElement).toBe(options[1]); - }); - }); -}); diff --git a/packages/react-aria/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index cf2dbe38864..15985ff3c46 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -24,11 +24,11 @@ import { } from '@react-types/shared'; import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; +import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {KeyboardEventHandler, useState} from 'react'; import {ListState} from 'react-stately/useListState'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; -import {useState} from 'react'; const BUTTON_GROUP_ROLES = { none: 'toolbar', @@ -91,30 +91,34 @@ export function useActionGroup( let {direction} = useLocale(); let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: () => { - if (flipDirection) { + let onKeyDown: KeyboardEventHandler = e => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return; + } + + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'ArrowRight' && flipDirection) { focusManager.focusPrevious({wrap: true}); } else { focusManager.focusNext({wrap: true}); } - }, - ArrowDown: () => { - focusManager.focusNext({wrap: true}); - }, - ArrowLeft: () => { - if (flipDirection) { + break; + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'ArrowLeft' && flipDirection) { focusManager.focusNext({wrap: true}); } else { focusManager.focusPrevious({wrap: true}); } - }, - ArrowUp: () => { - focusManager.focusPrevious({wrap: true}); - } + break; } - }); + }; let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode]; if (isInToolbar && role === 'toolbar') { @@ -126,7 +130,7 @@ export function useActionGroup( role, 'aria-orientation': role === 'toolbar' ? orientation : undefined, 'aria-disabled': isDisabled, - ...keyboardProps + onKeyDown } }; } diff --git a/packages/react-aria/src/calendar/useCalendarGrid.ts b/packages/react-aria/src/calendar/useCalendarGrid.ts index 1a1efb20057..d610ab780ab 100644 --- a/packages/react-aria/src/calendar/useCalendarGrid.ts +++ b/packages/react-aria/src/calendar/useCalendarGrid.ts @@ -14,13 +14,12 @@ import {CalendarDate, startOfWeek, today} from '@internationalized/date'; import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState'; import {DOMAttributes} from '@react-types/shared'; import {hookData, useVisibleRangeDescription} from './utils'; +import {KeyboardEvent, useMemo} from 'react'; import {mergeProps} from '../utils/mergeProps'; import {RangeCalendarState} from 'react-stately/useRangeCalendarState'; import {useDateFormatter} from '../i18n/useDateFormatter'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLocale} from '../i18n/I18nProvider'; -import {useMemo} from 'react'; export interface AriaCalendarGridProps { /** @@ -79,61 +78,70 @@ export function useCalendarGrid( let {direction} = useLocale(); - let {keyboardProps} = useKeyboard({ - shortcuts: { - Enter: () => { + let onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Enter': + case ' ': + e.preventDefault(); state.selectFocusedDate(); - }, - ' ': () => { - state.selectFocusedDate(); - }, - PageUp: () => { - state.focusPreviousSection(); - }, - 'Shift+PageUp': () => { - state.focusPreviousSection(true); - }, - PageDown: () => { - state.focusNextSection(); - }, - 'Shift+PageDown': () => { - state.focusNextSection(true); - }, - End: () => { + break; + case 'PageUp': + e.preventDefault(); + e.stopPropagation(); + state.focusPreviousSection(e.shiftKey); + break; + case 'PageDown': + e.preventDefault(); + e.stopPropagation(); + state.focusNextSection(e.shiftKey); + break; + case 'End': + e.preventDefault(); + e.stopPropagation(); state.focusSectionEnd(); - }, - Home: () => { + break; + case 'Home': + e.preventDefault(); + e.stopPropagation(); state.focusSectionStart(); - }, - ArrowLeft: () => { + break; + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { state.focusNextDay(); } else { state.focusPreviousDay(); } - }, - ArrowUp: () => { + break; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); state.focusPreviousRow(); - }, - ArrowRight: () => { + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { state.focusPreviousDay(); } else { state.focusNextDay(); } - }, - ArrowDown: () => { + break; + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); state.focusNextRow(); - }, - Escape: () => { + break; + case 'Escape': // Cancel the selection. if ('setAnchorDate' in state) { + e.preventDefault(); state.setAnchorDate(null); } - return false; // TODO: is this really correct? or should it return true when we cancel and only propagate if there's nothing to do - } + break; } - }); + }; let visibleRangeDescription = useVisibleRangeDescription( startDate, @@ -174,7 +182,7 @@ export function useCalendarGrid( 'aria-disabled': state.isDisabled || undefined, 'aria-multiselectable': 'highlightedRange' in state || state.selectionMode === 'multiple' || undefined, - ...keyboardProps, + onKeyDown, onFocus: () => state.setFocused(true), onBlur: () => state.setFocused(false) }), diff --git a/packages/react-aria/src/color/useColorArea.ts b/packages/react-aria/src/color/useColorArea.ts index c7a293abf70..8351e622c25 100644 --- a/packages/react-aria/src/color/useColorArea.ts +++ b/packages/react-aria/src/color/useColorArea.ts @@ -111,56 +111,46 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState) let currentPosition = useRef<{x: number; y: number} | null>(null); - let keyboardUpdate = (cb, inputRef: RefObject, input: 'x' | 'y') => { - state.setDragging(true); - setValueChangedViaKeyboard(true); - cb(); - state.setDragging(false); - focusInput(inputRef); - setFocusedInput(input); - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - return keyboardUpdate( - () => { - state.incrementY(state.yChannelPageStep); - }, - inputYRef, - 'y' - ); - }, - PageDown: () => { - return keyboardUpdate( - () => { - state.decrementY(state.yChannelPageStep); - }, - inputYRef, - 'y' - ); - }, - Home: () => { - return keyboardUpdate( - () => { - direction === 'rtl' - ? state.incrementX(state.xChannelPageStep) - : state.decrementX(state.xChannelPageStep); - }, - inputXRef, - 'x' - ); - }, - End: () => { - return keyboardUpdate( - () => { - direction === 'rtl' - ? state.decrementX(state.xChannelPageStep) - : state.incrementX(state.xChannelPageStep); - }, - inputXRef, - 'x' - ); + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + state.setDragging(true); + setValueChangedViaKeyboard(true); + let dir; + switch (e.key) { + case 'PageUp': + state.incrementY(state.yChannelPageStep); + dir = 'y'; + break; + case 'PageDown': + state.decrementY(state.yChannelPageStep); + dir = 'y'; + break; + case 'Home': + direction === 'rtl' + ? state.incrementX(state.xChannelPageStep) + : state.decrementX(state.xChannelPageStep); + dir = 'x'; + break; + case 'End': + direction === 'rtl' + ? state.decrementX(state.xChannelPageStep) + : state.incrementX(state.xChannelPageStep); + dir = 'x'; + break; + } + state.setDragging(false); + if (dir) { + let input = dir === 'x' ? inputXRef : inputYRef; + focusInput(input); + setFocusedInput(dir); } } }); diff --git a/packages/react-aria/src/color/useColorWheel.ts b/packages/react-aria/src/color/useColorWheel.ts index cd313393755..5a1edc2ca59 100644 --- a/packages/react-aria/src/color/useColorWheel.ts +++ b/packages/react-aria/src/color/useColorWheel.ts @@ -75,17 +75,27 @@ export function useColorWheel( let currentPosition = useRef<{x: number; y: number} | null>(null); let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - state.setDragging(true); - state.increment(state.pageStep); - state.setDragging(false); - }, - PageDown: () => { - state.setDragging(true); - state.decrement(state.pageStep); - state.setDragging(false); + onKeyDown(e) { + // these are the cases that useMove doesn't handle + if (!/^(PageUp|PageDown)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, don't need to stop propagation, useKeyboard will do that for us + e.preventDefault(); + // remember to set this and unset it so that onChangeEnd is fired + state.setDragging(true); + switch (e.key) { + case 'PageUp': + e.preventDefault(); + state.increment(state.pageStep); + break; + case 'PageDown': + e.preventDefault(); + state.decrement(state.pageStep); + break; } + state.setDragging(false); } }); diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 42fe109769c..7763da45591 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -15,6 +15,7 @@ import {AriaButtonProps} from '../button/useButton'; import {ariaHideOutside} from '../overlays/ariaHideOutside'; import { AriaLabelingProps, + BaseEvent, DOMAttributes, DOMProps, InputDOMProps, @@ -32,6 +33,7 @@ import {dispatchVirtualFocus} from '../focus/virtualFocus'; import { FocusEvent, InputHTMLAttributes, + KeyboardEvent, TouchEvent, useEffect, useMemo, @@ -51,7 +53,6 @@ import {privateValidationStateProp} from 'react-stately/private/form/useFormVali import {useEvent} from '../utils/useEvent'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useMenuTrigger} from '../menu/useMenuTrigger'; @@ -175,12 +176,19 @@ export function useComboBox( let router = useRouter(); - // for textfield specific operations - let {keyboardProps} = useKeyboard({ - shortcuts: { - Enter: e => { - // Prevent default form submission if menu is open since we may be selecting a option - let shouldPreventDefault = state.isOpen; + // For textfield specific keydown operations + let onKeyDown = (e: BaseEvent>) => { + if (e.nativeEvent.isComposing) { + return; + } + switch (e.key) { + case 'Enter': + case 'Tab': + // Prevent form submission if menu is open since we may be selecting a option + if (state.isOpen && e.key === 'Enter') { + e.preventDefault(); + } + // If the focused item is a link, trigger opening it. Items that are links are not selectable. if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) { let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); @@ -188,7 +196,7 @@ export function useComboBox( let item = listBoxRef.current.querySelector( `[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]` ); - if (item instanceof HTMLAnchorElement) { + if (e.key === 'Enter' && item instanceof HTMLAnchorElement) { router.open( item, e, @@ -197,56 +205,35 @@ export function useComboBox( ); } state.close(); - return {shouldPreventDefault}; + break; } else if (collectionItem?.props.onAction) { collectionItem.props.onAction(); state.close(); - return {shouldPreventDefault}; + break; } } - state.commit(); - return {shouldPreventDefault}; - }, - Tab: () => { - // If the focused item is a link, trigger opening it. Items that are links are not selectable. - if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) { - let collectionItem = state.collection.getItem(state.selectionManager.focusedKey); - if (collectionItem?.props.href) { - state.close(); - return {shouldPreventDefault: false}; - } else if (collectionItem?.props.onAction) { - collectionItem.props.onAction(); - state.close(); - return {shouldPreventDefault: false}; - } - } - if (state.isOpen) { + if (e.key === 'Enter' || state.isOpen) { state.commit(); } - return {shouldPreventDefault: false}; - }, - Escape: () => { - let shouldContinuePropagation = false; + break; + case 'Escape': if (!state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue) { - shouldContinuePropagation = true; + e.continuePropagation(); } state.revert(); - return {shouldContinuePropagation}; - }, - ArrowDown: () => { + break; + case 'ArrowDown': state.open('first', 'manual'); - }, - ArrowUp: () => { + break; + case 'ArrowUp': state.open('last', 'manual'); - }, - ArrowLeft: () => { - state.selectionManager.setFocusedKey(null); - }, - ArrowRight: () => { + break; + case 'ArrowLeft': + case 'ArrowRight': state.selectionManager.setFocusedKey(null); - } + break; } - }); + }; let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; @@ -290,7 +277,7 @@ export function useComboBox( : props.isRequired, onChange: state.setInputValue, onKeyDown: !isReadOnly - ? chain(state.isOpen && collectionProps.onKeyDown, keyboardProps.onKeyDown, props.onKeyDown) + ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, onBlur, value: state.inputValue, diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 5383ee5aa7e..cf2d5475dc9 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -16,6 +16,7 @@ import { DOMProps, GroupDOMAttributes, InputDOMProps, + KeyboardEvent, RefObject, ValidationResult } from '@react-types/shared'; @@ -213,8 +214,16 @@ export function useDateField( } }, fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps, { - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp, + onKeyDown(e: KeyboardEvent) { + if (props.onKeyDown) { + props.onKeyDown(e); + } + }, + onKeyUp(e: KeyboardEvent) { + if (props.onKeyUp) { + props.onKeyUp(e); + } + }, style: { unicodeBidi: 'isolate' } diff --git a/packages/react-aria/src/datepicker/useDatePickerGroup.ts b/packages/react-aria/src/datepicker/useDatePickerGroup.ts index 8d353286e20..3bf75fbe3df 100644 --- a/packages/react-aria/src/datepicker/useDatePickerGroup.ts +++ b/packages/react-aria/src/datepicker/useDatePickerGroup.ts @@ -2,10 +2,9 @@ import {createFocusManager, getFocusableTreeWalker} from '../focus/FocusScope'; import {DateFieldState} from 'react-stately/useDateFieldState'; import {DatePickerState} from 'react-stately/useDatePickerState'; import {DateRangePickerState} from 'react-stately/useDateRangePickerState'; -import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared'; -import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {DOMAttributes, FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared'; +import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {mergeProps} from '../utils/mergeProps'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useMemo} from 'react'; import {usePress} from '../interactions/usePress'; @@ -18,24 +17,26 @@ export function useDatePickerGroup( let {direction} = useLocale(); let focusManager = useMemo(() => createFocusManager(ref), [ref]); - let {keyboardProps} = useKeyboard({ - shortcuts: { - 'Alt+ArrowDown': () => { - if ('setOpen' in state) { - state.setOpen(true); - } - return false; - }, - 'Alt+ArrowUp': () => { - if ('setOpen' in state) { - state.setOpen(true); - } - return false; - }, - ArrowLeft: e => { - if (disableArrowNavigation) { - return false; - } + // Open the popover on alt + arrow down + let onKeyDown = (e: KeyboardEvent) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return; + } + + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && 'setOpen' in state) { + e.preventDefault(); + e.stopPropagation(); + state.setOpen(true); + } + + if (disableArrowNavigation) { + return; + } + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -43,19 +44,15 @@ export function useDatePickerGroup( if (prev) { prev.focus(); - return; } } } else { focusManager.focusPrevious(); - return; - } - return false; - }, - ArrowRight: e => { - if (disableArrowNavigation) { - return false; } + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -63,17 +60,14 @@ export function useDatePickerGroup( if (next) { next.focus(); - return; } } } else { focusManager.focusNext(); - return; } - return false; - } + break; } - }); + }; // Focus the first placeholder segment from the end on mouse down/touch up in the field. let focusLast = () => { @@ -129,7 +123,7 @@ export function useDatePickerGroup( } }); - return mergeProps(pressProps, keyboardProps); + return mergeProps(pressProps, {onKeyDown}); } function findNextSegment(group: Element, fromX: number, direction: number) { diff --git a/packages/react-aria/src/datepicker/useDateSegment.ts b/packages/react-aria/src/datepicker/useDateSegment.ts index 9119e2b7d76..0ecc714760f 100644 --- a/packages/react-aria/src/datepicker/useDateSegment.ts +++ b/packages/react-aria/src/datepicker/useDateSegment.ts @@ -15,7 +15,7 @@ import {DateFieldState, DateSegment} from 'react-stately/useDateFieldState'; import {getActiveElement, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getScrollParent} from '../utils/getScrollParent'; import {hookData} from './useDateField'; -import {isIOS} from '../utils/platform'; +import {isIOS, isMac} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; @@ -26,7 +26,6 @@ import {useDisplayNames} from './useDisplayNames'; import {useEvent} from '../utils/useEvent'; import {useFilter} from '../i18n/useFilter'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; @@ -126,20 +125,28 @@ export function useDateSegment( } }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - Backspace: () => { - backspace(); - }, - Delete: () => { + let onKeyDown = e => { + // Firefox does not fire selectstart for Ctrl/Cmd + A + // https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 + if (e.key === 'a' && (isMac() ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + } + + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) { + return; + } + + switch (e.key) { + case 'Backspace': + case 'Delete': { + // Safari on iOS does not fire beforeinput for the backspace key because the cursor is at the start. + e.preventDefault(); + e.stopPropagation(); backspace(); - }, - 'Mod+a': () => { - // Firefox does not fire selectstart for Ctrl/Cmd + A - // https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 + break; } } - }); + }; // Safari dayPeriod option doesn't work... let {startsWith} = useFilter({sensitivity: 'base'}); @@ -387,14 +394,13 @@ export function useDateSegment( segmentProps: mergeProps(spinButtonProps, labelProps, { id, ...touchPropOverrides, - ...keyboardProps, 'aria-invalid': state.isInvalid ? 'true' : undefined, 'aria-describedby': ariaDescribedBy, 'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined, 'data-placeholder': segment.isPlaceholder || undefined, contentEditable: isEditable, suppressContentEditableWarning: isEditable, - spellCheck: isEditable ? ('false' as const) : undefined, + spellCheck: isEditable ? 'false' : undefined, autoCorrect: isEditable ? 'off' : undefined, // Capitalization was changed in React 17... [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable @@ -403,8 +409,9 @@ export function useDateSegment( inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined - : ('numeric' as const), + : 'numeric', tabIndex: state.isDisabled ? undefined : 0, + onKeyDown, onFocus, style: segmentStyle, // Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment. diff --git a/packages/react-aria/src/interactions/createEventHandler.ts b/packages/react-aria/src/interactions/createEventHandler.ts index 0c9d6900f9d..88831ab4d9f 100644 --- a/packages/react-aria/src/interactions/createEventHandler.ts +++ b/packages/react-aria/src/interactions/createEventHandler.ts @@ -24,8 +24,8 @@ export function createEventHandler( return undefined; } + let shouldStopPropagation = true; return (e: T) => { - let shouldStopPropagation = true; let event: BaseEvent = { ...e, preventDefault() { @@ -53,11 +53,7 @@ export function createEventHandler( handler(event); - // nested createEventHandler calls may already have stopped propagation - if ( - shouldStopPropagation && - !(typeof e.isPropagationStopped === 'function' && e.isPropagationStopped()) - ) { + if (shouldStopPropagation) { e.stopPropagation(); } }; diff --git a/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts b/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts deleted file mode 100644 index 071bfe3ec64..00000000000 --- a/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import {isMac} from '../utils/platform'; -import {KeyboardEvent} from '@react-types/shared'; - -export type KeyboardShortcutAction = ( - e: KeyboardEvent -) => - | void - | boolean - | Partial<{shouldContinuePropagation?: boolean; shouldPreventDefault?: boolean}>; - -/** Maps shortcut strings (e.g. `"Mod+s"`, `"Control+Shift+a"`) to handlers. */ -export type KeyboardShortcutBindings = Record; - -/** Modifier names in shortcut strings (case-insensitive). Order in the string does not matter. */ -const MODIFIER_NAMES = new Set([ - 'shift', - 'alt', - 'control', - 'meta', - 'mod' // OS dependent - Cmd on Mac, Control on Windows/Linux -]); - -/** Canonical modifier order for stable keys (sorted, fixed order). */ -const CANONICAL_MODIFIER_ORDER = ['Alt', 'Control', 'Meta', 'Shift'] as const; - -export interface ParsedKeyboardShortcut { - shift: boolean; - alt: boolean; - ctrl: boolean; - meta: boolean; - /** - * Platform primary: Cmd on Mac, Control on Windows/Linux — expands to Meta or Control in - * canonical form. - */ - mod: boolean; - key: string; -} - -/** - * Builds the set of canonical modifier tokens for a binding. - * `Mod` contributes Meta (Mac) or Ctrl (non-Mac); explicit Ctrl/Meta add those keys too. - */ -export function modifierSetFromParsed(parsed: ParsedKeyboardShortcut): Set { - let set = new Set(); - if (parsed.alt) { - set.add('Alt'); - } - if (parsed.shift) { - set.add('Shift'); - } - if (parsed.ctrl) { - set.add('Control'); - } - if (parsed.meta) { - set.add('Meta'); - } - if (parsed.mod) { - set.add(isMac() ? 'Meta' : 'Control'); - } - return set; -} - -/** Modifier set from a keydown event (native flags only). */ -export function modifierSetFromEvent(e: KeyboardEvent): Set { - let set = new Set(); - if (e.altKey) { - set.add('Alt'); - } - if (e.ctrlKey) { - set.add('Control'); - } - if (e.metaKey) { - set.add('Meta'); - } - if (e.shiftKey) { - set.add('Shift'); - } - return set; -} - -function sortedModifierTokens(set: Set): string[] { - return CANONICAL_MODIFIER_ORDER.filter(name => set.has(name)); -} - -/** - * Parses a shortcut like `"Mod+Shift+z"`, `"Ctrl+Alt+Enter"`, or `"Escape"`. - * Modifiers are case-insensitive; order does not matter. `control` is an alias for `ctrl`. - */ -export function parseKeyboardShortcut(spec: string): ParsedKeyboardShortcut { - let parts = spec.split('+').reduce( - (prev, part) => { - let lower = part.toLowerCase(); - if (MODIFIER_NAMES.has(lower)) { - if (lower === 'shift') { - prev.shift = true; - } else if (lower === 'alt') { - prev.alt = true; - } else if (lower === 'control') { - prev.ctrl = true; - } else if (lower === 'meta') { - prev.meta = true; - } else if (lower === 'mod') { - prev.mod = true; - } - } else { - prev.key = part; - } - return prev; - }, - {shift: false, alt: false, ctrl: false, meta: false, mod: false, key: ''} - ); - if (parts.key === '') { - throw new Error( - `Invalid keyboard shortcut: "${spec}". Must include exactly one non-modifier key (e.g. "a", "Enter", "ArrowDown"). Combine any of Shift, Alt, Ctrl, Meta, and Mod.` - ); - } - return parts; -} - -function normalizeEventKey(key: string): string { - return key.toLowerCase(); -} - -/** Short aliases for common keys (shortcut side, before match). */ -const KEY_ALIASES: Record = { - space: ' ', - esc: 'escape', - del: 'delete', - ins: 'insert', - left: 'arrowleft', - right: 'arrowright', - up: 'arrowup', - down: 'arrowdown', - pageup: 'pageup', - pagedown: 'pagedown' -}; - -/** Canonical key segment (lowercase); aliases like `down` → `arrowdown`. */ -function canonicalKeyFromSpecKey(specKey: string): string { - let k = normalizeEventKey(specKey); - let aliased = KEY_ALIASES[k]; - return aliased != null ? aliased : k; -} - -/** Canonical shortcut string for a binding (modifiers sorted: Alt, Ctrl, Meta, Shift, then key). */ -export function canonicalKeyboardShortcut(parsed: ParsedKeyboardShortcut): string { - let mods = sortedModifierTokens(modifierSetFromParsed(parsed)); - let key = canonicalKeyFromSpecKey(parsed.key); - return mods.length > 0 ? `${mods.join('+')}+${key}` : key; -} - -/** Canonical shortcut string for a keydown event. */ -export function keyboardEventToCanonicalShortcut(e: KeyboardEvent): string { - let mods = sortedModifierTokens(modifierSetFromEvent(e)); - let key = normalizeEventKey(e.key); - let prefix = mods.length > 0 ? `${mods.join('+')}+` : ''; - return prefix + key; -} - -/** - * Returns a keydown handler that runs the action only for an exact modifier+key match. - * Modifier order in the string does not matter (`Shift+Mod+a` ≡ `Mod+Shift+a`). - * Any combination of **Shift**, **Alt**, **Ctrl**, **Meta**, and **Mod** is allowed; **Mod** means - * Cmd on Apple platforms and Ctrl on Windows/Linux (same as before). **control** aliases **ctrl**. - * - * Duplicate bindings that normalize to the same shortcut: later object entries win. - * - * @example - * ```tsx - * let onKeyDown = createKeyboardShortcutHandler({ - * 'Mod+s': e => { - * e.preventDefault(); - * save(); - * }, - * 'Ctrl+Shift+k': () => palette(), - * 'Meta+Alt+ArrowLeft': () => back() - * }); - * ```; - */ -export function createKeyboardShortcutHandler( - bindings: KeyboardShortcutBindings -): (e: KeyboardEvent) => void { - let map = new Map(); - for (let [spec, action] of Object.entries(bindings)) { - let parsed = parseKeyboardShortcut(spec); - map.set(canonicalKeyboardShortcut(parsed), action); - } - - return (e: KeyboardEvent) => { - let canonical = keyboardEventToCanonicalShortcut(e); - let action = map.get(canonical); - let result = action?.(e); - if (result === undefined && action !== undefined) { - result = {shouldContinuePropagation: false, shouldPreventDefault: true}; - } else if (typeof result === 'boolean') { - result = {shouldContinuePropagation: !result, shouldPreventDefault: result}; - } - if (result?.shouldPreventDefault) { - e.preventDefault(); - } - if (!action || result?.shouldContinuePropagation) { - e.continuePropagation(); - } - }; -} diff --git a/packages/react-aria/src/interactions/useKeyboard.ts b/packages/react-aria/src/interactions/useKeyboard.ts index 9264a58ac22..f7f99ca827d 100644 --- a/packages/react-aria/src/interactions/useKeyboard.ts +++ b/packages/react-aria/src/interactions/useKeyboard.ts @@ -11,21 +11,11 @@ */ import {createEventHandler} from './createEventHandler'; -import { - createKeyboardShortcutHandler, - KeyboardShortcutBindings -} from './createKeyboardShortcutHandler'; import {DOMAttributes, KeyboardEvents} from '@react-types/shared'; -import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {KeyboardEvent as ReactKeyboardEvent} from 'react'; export interface KeyboardProps extends KeyboardEvents { /** Whether the keyboard events should be disabled. */ isDisabled?: boolean; - /** Keyboard shortcuts to handle. */ - shortcuts?: KeyboardShortcutBindings; - allowRepeats?: boolean; - allowComposing?: boolean; } export interface KeyboardResult { @@ -37,57 +27,12 @@ export interface KeyboardResult { * Handles keyboard interactions for a focusable element. */ export function useKeyboard(props: KeyboardProps): KeyboardResult { - let {shortcuts, allowRepeats = false, allowComposing = false} = props; - let onKeyDown; - let onKeyUp; - if (shortcuts) { - let shortcutHandler = createKeyboardShortcutHandler(shortcuts); - onKeyDown = createEventHandler>(e => { - // If keyboard event didn't originate from a child of the current target, - // then it's a React event coming through a portal. We should ignore it. - if (!nodeContains(e.currentTarget, getEventTarget(e))) { - e.continuePropagation(); - return; - } - if ( - (e.nativeEvent?.repeat && !allowRepeats) || - (e.nativeEvent?.isComposing && !allowComposing) - ) { - e.continuePropagation(); - return; - } - - shortcutHandler(e); - props.onKeyDown?.(e); - }); - onKeyUp = createEventHandler>(e => { - // If keyboard event didn't originate from a child of the current target, - // then it's a React event coming through a portal. We should ignore it. - if (!nodeContains(e.currentTarget, getEventTarget(e))) { - e.continuePropagation(); - return; - } - if ( - (e.nativeEvent?.repeat && !allowRepeats) || - (e.nativeEvent?.isComposing && !allowComposing) - ) { - e.continuePropagation(); - return; - } - // implement shortcut handler on keyup, what should the map be called? or should it be another syntax on shortcuts? - e.continuePropagation(); - props.onKeyUp?.(e); - }); - } else { - onKeyDown = createEventHandler(props.onKeyDown); - onKeyUp = createEventHandler(props.onKeyUp); - } return { keyboardProps: props.isDisabled ? {} : { - onKeyDown, - onKeyUp + onKeyDown: createEventHandler(props.onKeyDown), + onKeyUp: createEventHandler(props.onKeyUp) } }; } diff --git a/packages/react-aria/src/menu/useMenuItem.ts b/packages/react-aria/src/menu/useMenuItem.ts index 8dc2f764860..dbea1ac2b12 100644 --- a/packages/react-aria/src/menu/useMenuItem.ts +++ b/packages/react-aria/src/menu/useMenuItem.ts @@ -314,34 +314,44 @@ export function useMenuItem( }); let {keyboardProps} = useKeyboard({ - shortcuts: { - ' ': e => { - interaction.current = {pointerType: 'keyboard', key: ' '}; - (getEventTarget(e) as HTMLElement).click(); - - // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus - // to the newly opened submenu's first item. - setInteractionModality('keyboard'); - }, - Enter: e => { - interaction.current = {pointerType: 'keyboard', key: 'Enter'}; - let target = getEventTarget(e) as HTMLElement; - - // Trigger click unless this is a link. Links with real DOM focus activate on Enter natively. - // With virtual focus (e.g. Autocomplete) focus stays on the input and useAutocomplete dispatches - // keydown here then follows with a synthetic click only if dispatchEvent was not canceled—so - // links must not preventDefault on that keydown. - if (target.tagName !== 'A') { - target.click(); + onKeyDown: e => { + // Ignore repeating events, which may have started on the menu trigger before moving + // focus to the menu item. We want to wait for a second complete key press sequence. + if (e.repeat) { + e.continuePropagation(); + return; + } + + switch (e.key) { + case ' ': + interaction.current = {pointerType: 'keyboard', key: ' '}; + (getEventTarget(e) as HTMLElement).click(); + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. setInteractionModality('keyboard'); - return; - } + break; + case 'Enter': + interaction.current = {pointerType: 'keyboard', key: 'Enter'}; - setInteractionModality('keyboard'); - return {shouldPreventDefault: false, shouldContinuePropagation: false}; + // Trigger click unless this is a link. Links trigger click natively. + if ((getEventTarget(e) as HTMLElement).tagName !== 'A') { + (getEventTarget(e) as HTMLElement).click(); + } + + // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus + // to the newly opened submenu's first item. + setInteractionModality('keyboard'); + break; + default: + if (!isTrigger) { + e.continuePropagation(); + } + + onKeyDown?.(e); + break; } }, - onKeyDown, onKeyUp }); diff --git a/packages/react-aria/src/menu/useMenuTrigger.ts b/packages/react-aria/src/menu/useMenuTrigger.ts index 61605da99ec..2cda3915319 100644 --- a/packages/react-aria/src/menu/useMenuTrigger.ts +++ b/packages/react-aria/src/menu/useMenuTrigger.ts @@ -12,13 +12,12 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaMenuOptions} from './useMenu'; -import {FocusableElement, FocusStrategy, KeyboardEvent, RefObject} from '@react-types/shared'; +import {FocusableElement, RefObject} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import intlMessages from '../../intl/menu/*.json'; import {MenuTriggerState, MenuTriggerType} from 'react-stately/useMenuTriggerState'; import {PressProps} from '../interactions/usePress'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useLongPress} from '../interactions/useLongPress'; import {useOverlayTrigger} from '../overlays/useOverlayTrigger'; @@ -57,53 +56,50 @@ export function useMenuTrigger( let menuTriggerId = useId(); let {triggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); - let open = ( - shouldOpen: boolean, - e: KeyboardEvent, - focusStrategy: FocusStrategy = 'first' - ): boolean | void => { - if (!shouldOpen || e.isDefaultPrevented()) { - return false; + let onKeyDown = e => { + if (isDisabled) { + return; + } + + if (trigger === 'longPress' && !e.altKey) { + return; } - state.toggle(focusStrategy); - }; - // React puts listeners on the same root, so even if propagation was stopped, immediate propagation is still possible. - // useTypeSelect will handle the spacebar first if it's running, so we don't want to open if it's handled it already. - // We use isDefaultPrevented() instead of isPropagationStopped() because createEventHandler stops propagation by default. - // And default prevented means that the event was handled by something else (typeahead), so we don't want to open the menu. - let {keyboardProps} = useKeyboard({ - isDisabled, - shortcuts: { - Enter: e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ' ': e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ArrowDown: e => { - return open(trigger !== 'longPress', e, 'first'); - }, - ArrowUp: e => { - return open(trigger !== 'longPress', e, 'last'); - }, - 'Alt+Enter': e => { - return open(trigger === 'longPress', e, 'first'); - }, - 'Alt+ ': e => { - return open(trigger === 'longPress', e, 'first'); - }, - // Alt+Arrow* must open for both trigger modes: for `press` it matches the same `e.key` cases as - // plain Arrow*; for `longPress`, plain arrows are ignored elsewhere and Alt+Arrow is the opener - // (see legacy `if (trigger === 'longPress' && !e.altKey) return` before the ArrowDown/Up switch). - 'Alt+ArrowDown': e => { - return open(true, e, 'first'); - }, - 'Alt+ArrowUp': e => { - return open(true, e, 'last'); + if (ref && ref.current) { + switch (e.key) { + case 'Enter': + case ' ': + // React puts listeners on the same root, so even if propagation was stopped, immediate propagation is still possible. + // useTypeSelect will handle the spacebar first if it's running, so we don't want to open if it's handled it already. + // We use isDefaultPrevented() instead of isPropagationStopped() because createEventHandler stops propagation by default. + // And default prevented means that the event was handled by something else (typeahead), so we don't want to open the menu. + if (trigger === 'longPress' || e.isDefaultPrevented()) { + return; + } + // fallthrough + case 'ArrowDown': + // Stop propagation, unless it would already be handled by useKeyboard. + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + e.preventDefault(); + state.toggle('first'); + break; + case 'ArrowUp': + if (!('continuePropagation' in e)) { + e.stopPropagation(); + } + e.preventDefault(); + state.toggle('last'); + break; + default: + // Allow other keys. + if ('continuePropagation' in e) { + e.continuePropagation(); + } } } - }); + }; let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/menu'); let {longPressProps} = useLongPress({ @@ -148,8 +144,8 @@ export function useMenuTrigger( menuTriggerProps: { ...triggerProps, ...(trigger === 'press' ? pressProps : longPressProps), - ...keyboardProps, - id: menuTriggerId + id: menuTriggerId, + onKeyDown }, menuProps: { ...overlayProps, diff --git a/packages/react-aria/src/menu/useSubmenuTrigger.ts b/packages/react-aria/src/menu/useSubmenuTrigger.ts index 9adaa754e83..21d6ffaf6ca 100644 --- a/packages/react-aria/src/menu/useSubmenuTrigger.ts +++ b/packages/react-aria/src/menu/useSubmenuTrigger.ts @@ -13,7 +13,14 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps} from '../overlays/usePopover'; -import {FocusableElement, FocusStrategy, Node, PressEvent, RefObject} from '@react-types/shared'; +import { + FocusableElement, + FocusStrategy, + KeyboardEvent, + Node, + PressEvent, + RefObject +} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import { getActiveElement, @@ -26,7 +33,6 @@ import type {SubmenuTriggerState} from 'react-stately/useMenuTriggerState'; import {useCallback, useRef} from 'react'; import {useEvent} from '../utils/useEvent'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu'; @@ -128,51 +134,46 @@ export function useSubmenuTrigger( }; }, [cancelOpenTimeout]); - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowLeft: e => { - // If focus is not within the menu, assume virtual focus is being used. - // This means some other input element is also within the popover, so we shouldn't close the menu. - if (!isFocusWithin(e.currentTarget)) { - return false; - } + let submenuKeyDown = (e: KeyboardEvent) => { + // If focus is not within the menu, assume virtual focus is being used. + // This means some other input element is also within the popover, so we shouldn't close the menu. + if (!isFocusWithin(e.currentTarget)) { + return; + } + + switch (e.key) { + case 'ArrowLeft': if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + e.preventDefault(); + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; - } - return false; - }, - ArrowRight: e => { - if (!isFocusWithin(e.currentTarget)) { - return false; } + break; + case 'ArrowRight': if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + e.preventDefault(); + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; - } - return false; - }, - Escape: e => { - if (!isFocusWithin(e.currentTarget)) { - return false; } + break; + case 'Escape': + // TODO: can remove this when we fix collection event leaks if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) { + e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } - return; } - return false; - } + break; } - }); + }; let submenuProps = { id: overlayId, @@ -181,15 +182,16 @@ export function useSubmenuTrigger( ...(type === 'menu' && { onClose: state.closeAll, autoFocus: state.focusStrategy ?? undefined, - ...keyboardProps + onKeyDown: submenuKeyDown }) }; - let {keyboardProps: submenuTriggerKeyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: () => { + let submenuTriggerKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowRight': if (!isDisabled) { if (direction === 'ltr') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -197,19 +199,18 @@ export function useSubmenuTrigger( if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } - return; } else if (state.isOpen) { onSubmenuClose(); - return; } else { - return false; + e.continuePropagation(); } } - return false; - }, - ArrowLeft: () => { + + break; + case 'ArrowLeft': if (!isDisabled) { if (direction === 'rtl') { + e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -217,18 +218,18 @@ export function useSubmenuTrigger( if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } - return; } else if (state.isOpen) { onSubmenuClose(); - return; } else { - return false; + e.continuePropagation(); } } - return false; - } + break; + default: + e.continuePropagation(); + break; } - }); + }; let onPressStart = (e: PressEvent) => { if (!isDisabled && (e.pointerType === 'virtual' || e.pointerType === 'keyboard')) { @@ -288,7 +289,6 @@ export function useSubmenuTrigger( return { submenuTriggerProps: { - ...(submenuTriggerKeyboardProps as any), // TODO: fix this id: submenuTriggerId, 'aria-controls': state.isOpen ? overlayId : undefined, 'aria-haspopup': !isDisabled ? type : undefined, @@ -296,6 +296,7 @@ export function useSubmenuTrigger( onPressStart, onPress, onHoverChange, + onKeyDown: submenuTriggerKeyDown, isOpen: state.isOpen }, submenuProps, diff --git a/packages/react-aria/src/numberfield/useNumberField.ts b/packages/react-aria/src/numberfield/useNumberField.ts index f6360aa4b2f..aebb6446e4c 100644 --- a/packages/react-aria/src/numberfield/useNumberField.ts +++ b/packages/react-aria/src/numberfield/useNumberField.ts @@ -21,6 +21,7 @@ import { TextInputDOMProps, ValidationResult } from '@react-types/shared'; +import {chain} from '../utils/chain'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -44,7 +45,6 @@ import {useFocusWithin} from '../interactions/useFocusWithin'; import {useFormattedTextField} from '../textfield/useFormattedTextField'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useNumberFormatter} from '../i18n/useNumberFormatter'; @@ -255,24 +255,28 @@ export function useNumberField( }; let domProps = filterDOMProps(props); - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - Enter: () => { + let onKeyDownEnter = useCallback( + e => { + if (e.nativeEvent.isComposing) { + return; + } + + if (e.key === 'Enter') { flushSync(() => { commit(); }); commitValidation(); + } else { + e.continuePropagation(); } }, - onKeyDown, - onKeyUp - }); + [commit, commitValidation] + ); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let { labelProps, - inputProps: textFieldPropsFromHook, + inputProps: textFieldProps, descriptionProps, errorMessageProps } = useFormattedTextField( @@ -301,6 +305,8 @@ export function useNumberField( onBlur, onFocus, onFocusChange, + onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), + onKeyUp, onPaste, description, errorMessage @@ -309,11 +315,6 @@ export function useNumberField( inputRef ); - // Merge outside useFormattedTextField so useKeyboard's createEventHandler is not nested inside - // useTextField/useFocusable's createEventHandler (avoids redundant stopPropagation on RS events). - // Shortcuts run first (mergeProps chains the second argument after the first). - let textFieldProps = mergeProps(keyboardProps, textFieldPropsFromHook); - useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue); useNativeValidation( state, diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1f2f476f4f8..1e844bf6427 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -16,7 +16,6 @@ import {isElementInChildOfActiveScope} from '../focus/FocusScope'; import {useEffect, useRef} from 'react'; import {useFocusWithin} from '../interactions/useFocusWithin'; import {useInteractOutside} from '../interactions/useInteractOutside'; -import {useKeyboard} from '../interactions/useKeyboard'; export interface AriaOverlayProps { /** Whether the overlay is currently open. */ @@ -126,17 +125,13 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!isKeyboardDismissDisabled) { - onHide(); - return; - } - return false; - } + let onKeyDown = e => { + if (e.key === 'Escape' && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { + e.stopPropagation(); + e.preventDefault(); + onHide(); } - }); + }; // Handle clicking outside the overlay to close it useInteractOutside({ @@ -172,7 +167,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { + let nextDir; + switch (e.key) { + case 'ArrowRight': + if (direction === 'rtl' && orientation !== 'vertical') { + nextDir = 'prev'; + } else { + nextDir = 'next'; + } + break; + case 'ArrowLeft': + if (direction === 'rtl' && orientation !== 'vertical') { + nextDir = 'next'; + } else { + nextDir = 'prev'; + } + break; + case 'ArrowDown': + nextDir = 'next'; + break; + case 'ArrowUp': + nextDir = 'prev'; + break; + default: + return; + } + e.preventDefault(); let walker = getFocusableTreeWalker(e.currentTarget, { from: getEventTarget(e) as Element, accept: node => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio' @@ -109,36 +134,12 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState nextElem = walker.lastChild(); } } - if (nextElem) { // Call focus on nextElem so that keyboard navigation scrolls the radio into view nextElem.focus(); state.setSelectedValue(nextElem.value); - return true; } - return false; - } - - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowRight: e => { - let nextDir: 'next' | 'prev' = - direction === 'rtl' && orientation !== 'vertical' ? 'prev' : 'next'; - return getNextElement(nextDir, e); - }, - ArrowLeft: e => { - let nextDir: 'next' | 'prev' = - direction === 'rtl' && orientation !== 'vertical' ? 'next' : 'prev'; - return getNextElement(nextDir, e); - }, - ArrowDown: e => { - return getNextElement('next', e); - }, - ArrowUp: e => { - return getNextElement('prev', e); - } - } - }); + }; let groupName = useId(name); radioGroupData.set(state, { @@ -153,7 +154,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState radioGroupProps: mergeProps(domProps, { // https://www.w3.org/TR/wai-aria-1.2/#radiogroup role: 'radiogroup', - ...keyboardProps, + onKeyDown, 'aria-invalid': state.isInvalid || undefined, 'aria-errormessage': props['aria-errormessage'], 'aria-readonly': isReadOnly || undefined, diff --git a/packages/react-aria/src/searchfield/useSearchField.ts b/packages/react-aria/src/searchfield/useSearchField.ts index 6278919130f..bde44998ae8 100644 --- a/packages/react-aria/src/searchfield/useSearchField.ts +++ b/packages/react-aria/src/searchfield/useSearchField.ts @@ -12,13 +12,11 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaTextFieldProps, useTextField} from '../textfield/useTextField'; +import {chain} from '../utils/chain'; import {DOMAttributes, RefObject, ValidationResult} from '@react-types/shared'; import {InputHTMLAttributes, LabelHTMLAttributes} from 'react'; import intlMessages from '../../intl/searchfield/*.json'; -// @ts-ignore -import {mergeProps} from '../utils/mergeProps'; import {SearchFieldProps, SearchFieldState} from 'react-stately/useSearchFieldState'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; export interface AriaSearchFieldProps extends SearchFieldProps, Omit { @@ -65,29 +63,38 @@ export function useSearchField( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/searchfield'); let {isDisabled, isReadOnly, onSubmit, onClear, type = 'search'} = props; - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - Enter: () => { - if (onSubmit) { - // for backward compatibility; - // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior - onSubmit(state.value); - return; - } - return false; - }, - Escape: () => { - // Also check the inputRef value for the case where the value was set directly on the input element instead of going through - // the hook - if (state.value === '' && (!inputRef.current || inputRef.current.value === '')) { - return false; - } + let onKeyDown = e => { + const key = e.key; + + if (key === 'Enter' && (isDisabled || isReadOnly)) { + e.preventDefault(); + } + + if (isDisabled || isReadOnly) { + return; + } + + // for backward compatibility; + // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior + if (key === 'Enter' && onSubmit) { + e.preventDefault(); + onSubmit(state.value); + } + + if (key === 'Escape') { + // Also check the inputRef value for the case where the value was set directly on the input element instead of going through + // the hook + if (state.value === '' && (!inputRef.current || inputRef.current.value === '')) { + e.continuePropagation(); + } else { + e.preventDefault(); state.setValue(''); - onClear?.(); + if (onClear) { + onClear(); + } } } - }); + }; let onClearButtonClick = () => { state.setValue(''); @@ -108,8 +115,7 @@ export function useSearchField( ...props, value: state.value, onChange: state.setValue, - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp, + onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, type }, inputRef @@ -117,14 +123,11 @@ export function useSearchField( return { labelProps, - // An edge case, in Autocomplete, if the keyboard hanlders are not in this order, then - // Escape runs autocomplete/listbox first, then the search-field shortcut returns false and - // continues propagation, leaking Escape to a parent Dialog. - inputProps: mergeProps(keyboardProps, { + inputProps: { ...inputProps, // already handled by useSearchFieldState defaultValue: undefined - }), + }, clearButtonProps: { 'aria-label': stringFormatter.format('Clear search'), excludeFromTabOrder: true, diff --git a/packages/react-aria/src/select/useSelect.ts b/packages/react-aria/src/select/useSelect.ts index 7e54b43ae4e..470a72555d3 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -34,7 +34,6 @@ import {setInteractionModality} from '../interactions/useFocusVisible'; import {useCollator} from '../i18n/useCollator'; import {useField} from '../label/useField'; import {useId} from '../utils/useId'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useMenuTrigger} from '../menu/useMenuTrigger'; import {useTypeSelect} from '../selection/useTypeSelect'; @@ -137,12 +136,16 @@ export function useSelect( ref ); - let {keyboardProps} = useKeyboard({ - shortcuts: { - ArrowLeft: () => { - if (state.selectionManager.selectionMode === 'multiple') { - return false; - } + let onKeyDown = (e: KeyboardEvent) => { + if (state.selectionManager.selectionMode === 'multiple') { + return; + } + + switch (e.key) { + case 'ArrowLeft': { + // prevent scrolling containers + e.preventDefault(); + let key = state.selectedKey != null ? delegate.getKeyAbove?.(state.selectedKey) @@ -150,11 +153,12 @@ export function useSelect( if (key != null) { state.setSelectedKey(key); } - }, - ArrowRight: () => { - if (state.selectionManager.selectionMode === 'multiple') { - return false; - } + break; + } + case 'ArrowRight': { + // prevent scrolling containers + e.preventDefault(); + let key = state.selectedKey != null ? delegate.getKeyBelow?.(state.selectedKey) @@ -162,11 +166,10 @@ export function useSelect( if (key != null) { state.setSelectedKey(key); } + break; } - }, - onKeyDown: props.onKeyDown, - onKeyUp: props.onKeyUp - }); + } + }; let {typeSelectProps} = useTypeSelect({ keyboardDelegate: delegate, @@ -218,8 +221,8 @@ export function useSelect( triggerProps: mergeProps(domProps, { ...triggerProps, isDisabled, - onKeyDown: chain(triggerProps.onKeyDown, keyboardProps.onKeyDown), - onKeyUp: keyboardProps.onKeyUp, + onKeyDown: chain(triggerProps.onKeyDown, onKeyDown, props.onKeyDown), + onKeyUp: props.onKeyUp, 'aria-labelledby': [ valueId, triggerProps['aria-labelledby'], diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index b06482a1086..a834398112d 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -19,11 +19,10 @@ import { FocusStrategy, Key, KeyboardDelegate, - KeyboardEvent, RefObject } from '@react-types/shared'; import {flushSync} from 'react-dom'; -import {FocusEvent, useEffect, useRef} from 'react'; +import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely} from '../interactions/focusSafely'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import { @@ -36,13 +35,11 @@ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; -import {isMac} from '../utils/platform'; import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; import {scrollIntoView, scrollIntoViewport} from '../utils/scrollIntoView'; import {useEvent} from '../utils/useEvent'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useRouter} from '../utils/openLink'; import {useTypeSelect} from './useTypeSelect'; @@ -170,266 +167,234 @@ export function useSelectableCollection( let {direction} = useLocale(); let router = useRouter(); - const navigateToKey = ( - e: KeyboardEvent, - key: Key | undefined, - childFocus?: FocusStrategy - ): boolean | void => { - if (key != null) { - if ( - manager.isLink(key) && - linkBehavior === 'selection' && - selectOnFocus && - !isNonContiguousSelectionModifier(e) - ) { - // Set focused key and re-render synchronously to bring item into view if needed. - flushSync(() => { - manager.setFocusedKey(key, childFocus); - }); - - let item = getItemElement(ref, key); - let itemProps = manager.getItemProps(key); - if (item) { - router.open(item, e, itemProps.href, itemProps.routerOptions); - return; - } - - return false; - } - - manager.setFocusedKey(key, childFocus); - - if (manager.isLink(key) && linkBehavior === 'override') { - return false; - } - - if (e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(key); - return; - } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { - manager.replaceSelection(key); - return; - } + let onKeyDown = (e: KeyboardEvent) => { + // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes + if (e.altKey && e.key === 'Tab') { + e.preventDefault(); } - return false; - }; - let arrowDown = (e: KeyboardEvent) => { - if (delegate.getKeyBelow) { - let nextKey = - manager.focusedKey != null - ? delegate.getKeyBelow?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getFirstKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } + // Keyboard events bubble through portals. Don't handle keyboard events + // for elements outside the collection (e.g. menus). + if (!ref.current || !nodeContains(ref.current, getEventTarget(e) as Element)) { + return; } - return false; - }; - let arrowUp = (e: KeyboardEvent) => { - if (delegate.getKeyAbove) { - let nextKey = - manager.focusedKey != null - ? delegate.getKeyAbove?.(manager.focusedKey) - : delegate.getLastKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getLastKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; + const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => { + if (key != null) { + if ( + manager.isLink(key) && + linkBehavior === 'selection' && + selectOnFocus && + !isNonContiguousSelectionModifier(e) + ) { + // Set focused key and re-render synchronously to bring item into view if needed. + flushSync(() => { + manager.setFocusedKey(key, childFocus); + }); + + let item = getItemElement(ref, key); + let itemProps = manager.getItemProps(key); + if (item) { + router.open(item, e, itemProps.href, itemProps.routerOptions); + } - let home = (e: KeyboardEvent) => { - if (delegate.getFirstKey) { - if (manager.focusedKey === null && e.shiftKey) { - return false; - } - // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) - let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); - manager.setFocusedKey(firstKey); - if (firstKey != null) { - if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(firstKey); - return; - } else if (selectOnFocus) { - manager.replaceSelection(firstKey); return; } - } - } - return false; - }; - let arrowLeft = (e: KeyboardEvent) => { - if (delegate.getKeyLeftOf) { - let nextKey: Key | undefined | null = - manager.focusedKey != null - ? delegate.getKeyLeftOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = - direction === 'rtl' - ? delegate.getFirstKey?.(manager.focusedKey) - : delegate.getLastKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey, direction === 'rtl' ? 'first' : 'last'); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; + manager.setFocusedKey(key, childFocus); - let arrowRight = (e: KeyboardEvent) => { - if (delegate.getKeyRightOf) { - let nextKey: Key | undefined | null = - manager.focusedKey != null - ? delegate.getKeyRightOf?.(manager.focusedKey) - : delegate.getFirstKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = - direction === 'rtl' - ? delegate.getLastKey?.(manager.focusedKey) - : delegate.getFirstKey?.(manager.focusedKey); - } - if (nextKey != null) { - navigateToKey(e, nextKey, direction === 'rtl' ? 'last' : 'first'); - return {shouldPreventDefault: true, shouldContinuePropagation: true}; - } - } - return false; - }; - - let end = (e: KeyboardEvent) => { - if (delegate.getLastKey) { - if (manager.focusedKey === null && e.shiftKey) { - return false; - } - let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); - manager.setFocusedKey(lastKey); - if (lastKey != null) { - if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(lastKey); - return; - } else if (selectOnFocus) { - manager.replaceSelection(lastKey); + if (manager.isLink(key) && linkBehavior === 'override') { return; } - } - } - return false; - }; - let pageDown = (e: KeyboardEvent) => { - if (delegate.getKeyPageBelow && manager.focusedKey != null) { - let nextKey = delegate.getKeyPageBelow(manager.focusedKey); - if (nextKey != null) { - return navigateToKey(e, nextKey); + if (e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(key); + } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { + manager.replaceSelection(key); + } } - } - return false; - }; + }; - let pageUp = (e: KeyboardEvent) => { - if (delegate.getKeyPageAbove && manager.focusedKey != null) { - let nextKey = delegate.getKeyPageAbove(manager.focusedKey); - if (nextKey != null) { - return navigateToKey(e, nextKey); + switch (e.key) { + case 'ArrowDown': { + if (delegate.getKeyBelow) { + let nextKey = + manager.focusedKey != null + ? delegate.getKeyBelow?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = delegate.getFirstKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + } + case 'ArrowUp': { + if (delegate.getKeyAbove) { + let nextKey = + manager.focusedKey != null + ? delegate.getKeyAbove?.(manager.focusedKey) + : delegate.getLastKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = delegate.getLastKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + } + case 'ArrowLeft': { + if (delegate.getKeyLeftOf) { + let nextKey: Key | undefined | null = + manager.focusedKey != null + ? delegate.getKeyLeftOf?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = + direction === 'rtl' + ? delegate.getFirstKey?.(manager.focusedKey) + : delegate.getLastKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); + } + } + break; + } + case 'ArrowRight': { + if (delegate.getKeyRightOf) { + let nextKey: Key | undefined | null = + manager.focusedKey != null + ? delegate.getKeyRightOf?.(manager.focusedKey) + : delegate.getFirstKey?.(); + if (nextKey == null && shouldFocusWrap) { + nextKey = + direction === 'rtl' + ? delegate.getLastKey?.(manager.focusedKey) + : delegate.getFirstKey?.(manager.focusedKey); + } + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + } + } + break; } - } - return false; - }; - - let aHandler = () => { - if (manager.selectionMode === 'multiple' && disallowSelectAll !== true) { - manager.selectAll(); - return; - } - return false; - }; - - let escape = () => { - if ( - escapeKeyBehavior === 'clearSelection' && - !disallowEmptySelection && - manager.selectedKeys.size !== 0 - ) { - manager.clearSelection(); - return; - } - return false; - }; - - let tab = () => { - if (!allowsTabNavigation && ref.current) { - // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). - // However, collections should be treated as a single tab stop, with arrow key navigation internally. - // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. - // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element - // in the collection, so that the browser default behavior will apply starting from that element - // rather than the currently focused one. - - let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); - let next: FocusableElement | undefined = undefined; - let last: FocusableElement; - do { - last = walker.lastChild() as FocusableElement; - if (last) { - next = last; + case 'Home': + if (delegate.getFirstKey) { + if (manager.focusedKey === null && e.shiftKey) { + return; + } + // TODO: should Home and End also be reversed in column reverse aka Home goes to top? Or should Home always to to the "first" (bottom) + e.preventDefault(); + let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); + manager.setFocusedKey(firstKey); + if (firstKey != null) { + if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(firstKey); + } else if (selectOnFocus) { + manager.replaceSelection(firstKey); + } + } + } + break; + case 'End': + if (delegate.getLastKey) { + if (manager.focusedKey === null && e.shiftKey) { + return; + } + e.preventDefault(); + let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); + manager.setFocusedKey(lastKey); + if (lastKey != null) { + if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(lastKey); + } else if (selectOnFocus) { + manager.replaceSelection(lastKey); + } + } + } + break; + case 'PageDown': + if (delegate.getKeyPageBelow && manager.focusedKey != null) { + let nextKey = delegate.getKeyPageBelow(manager.focusedKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + case 'PageUp': + if (delegate.getKeyPageAbove && manager.focusedKey != null) { + let nextKey = delegate.getKeyPageAbove(manager.focusedKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } + } + break; + case 'a': + if ( + isCtrlKeyPressed(e) && + manager.selectionMode === 'multiple' && + disallowSelectAll !== true + ) { + e.preventDefault(); + manager.selectAll(); + } + break; + case 'Escape': + if ( + escapeKeyBehavior === 'clearSelection' && + !disallowEmptySelection && + manager.selectedKeys.size !== 0 + ) { + e.stopPropagation(); + e.preventDefault(); + manager.clearSelection(); + } + break; + case 'Tab': { + if (!allowsTabNavigation) { + // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). + // However, collections should be treated as a single tab stop, with arrow key navigation internally. + // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. + // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element + // in the collection, so that the browser default behavior will apply starting from that element + // rather than the currently focused one. + if (e.shiftKey) { + ref.current.focus(); + } else { + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + let next: FocusableElement | undefined = undefined; + let last: FocusableElement; + do { + last = walker.lastChild() as FocusableElement; + // oxlint-disable-next-line max-depth + if (last) { + next = last; + } + } while (last); + + // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to + // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between + // focusing the containing cell and back to the non-tabbable child element + let activeElement = getActiveElement(); + if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { + focusWithoutScrolling(next); + } + } + break; } - } while (last); - - // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to - // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between - // focusing the containing cell and back to the non-tabbable child element - let activeElement = getActiveElement(); - if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { - focusWithoutScrolling(next); } } - return {shouldContinuePropagation: true, shouldPreventDefault: false}; - }; - - let shiftTab = () => { - if (!allowsTabNavigation && ref.current) { - ref.current.focus(); - } - return {shouldContinuePropagation: true, shouldPreventDefault: false}; - }; - - let withShiftSel = (key, callback) => { - return { - [isMac() ? key + '+Shift+Alt' : key + '+Shift+Control']: callback, - [key + '+Shift']: callback, - [isMac() ? key + '+Alt' : key + '+Control']: callback, - [key]: callback - }; }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - ...withShiftSel('ArrowDown', arrowDown), - ...withShiftSel('ArrowUp', arrowUp), - ...withShiftSel('ArrowLeft', arrowLeft), - ...withShiftSel('ArrowRight', arrowRight), - ...withShiftSel('Home', home), - ...withShiftSel('End', end), - ...withShiftSel('PageDown', pageDown), - ...withShiftSel('PageUp', pageUp), - [isMac() ? 'a+Alt' : 'a+Control']: aHandler, - Escape: escape, - Tab: tab, - 'Tab+Shift': shiftTab - } - }); // Store the scroll position so we can restore it later. /// TODO: should this happen all the time?? @@ -695,7 +660,7 @@ export function useSelectableCollection( }); let handlers = { - ...keyboardProps, + onKeyDown, onFocus, onBlur, onMouseDown(e) { diff --git a/packages/react-aria/src/slider/useSliderThumb.ts b/packages/react-aria/src/slider/useSliderThumb.ts index a077d07baab..efaaa0b50f8 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -142,35 +142,41 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let reverseX = direction === 'rtl'; let currentPosition = useRef(null); - let keyboardUpdate = cb => { - // remember to set this so that onChangeEnd is fired - state.setThumbDragging(index, true); - cb(); - state.setThumbDragging(index, false); - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - PageUp: () => { - return keyboardUpdate(() => { - state.incrementThumb(index, state.pageSize); - }); - }, - PageDown: () => { - return keyboardUpdate(() => { - state.decrementThumb(index, state.pageSize); - }); - }, - Home: () => { - return keyboardUpdate(() => { - state.setThumbValue(index, state.getThumbMinValue(index)); - }); - }, - End: () => { - return keyboardUpdate(() => { - state.setThumbValue(index, state.getThumbMaxValue(index)); - }); + onKeyDown(e) { + let { + getThumbMaxValue, + getThumbMinValue, + decrementThumb, + incrementThumb, + setThumbValue, + setThumbDragging, + pageSize + } = state; + // these are the cases that useMove or useSlider don't handle + if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) { + e.continuePropagation(); + return; + } + // same handling as useMove, stopPropagation to prevent useSlider from handling the event as well. + e.preventDefault(); + // remember to set this so that onChangeEnd is fired + setThumbDragging(index, true); + switch (e.key) { + case 'PageUp': + incrementThumb(index, pageSize); + break; + case 'PageDown': + decrementThumb(index, pageSize); + break; + case 'Home': + setThumbValue(index, getThumbMinValue(index)); + break; + case 'End': + setThumbValue(index, getThumbMaxValue(index)); + break; } + setThumbDragging(index, false); } }); diff --git a/packages/react-aria/src/spinbutton/useSpinButton.ts b/packages/react-aria/src/spinbutton/useSpinButton.ts index 171419b46ac..39e214ef995 100644 --- a/packages/react-aria/src/spinbutton/useSpinButton.ts +++ b/packages/react-aria/src/spinbutton/useSpinButton.ts @@ -18,7 +18,6 @@ import intlMessages from '../../intl/spinbutton/*.json'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useEffectEvent} from '../utils/useEffectEvent'; import {useGlobalListeners} from '../utils/useGlobalListeners'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; const noop = () => {}; @@ -72,61 +71,61 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { return () => clearAsyncEvent(); }, []); - let {keyboardProps} = useKeyboard({ - isDisabled: isDisabled || isReadOnly, - shortcuts: { - PageUp: () => { + let onKeyDown = e => { + if ( + e.ctrlKey || + e.metaKey || + e.shiftKey || + e.altKey || + isReadOnly || + e.nativeEvent.isComposing + ) { + return; + } + + switch (e.key) { + case 'PageUp': if (onIncrementPage) { - onIncrementPage(); - return; + e.preventDefault(); + onIncrementPage?.(); + break; } + // fallthrough! + case 'ArrowUp': + case 'Up': if (onIncrement) { - onIncrement(); - return; - } - return false; - }, - ArrowUp: () => { - if (onIncrement) { - onIncrement(); - return; + e.preventDefault(); + onIncrement?.(); } - return false; - }, - PageDown: () => { + break; + case 'PageDown': if (onDecrementPage) { - onDecrementPage(); - return; + e.preventDefault(); + onDecrementPage?.(); + break; } + // fallthrough + case 'ArrowDown': + case 'Down': if (onDecrement) { - onDecrement(); - return; - } - return false; - }, - ArrowDown: () => { - if (onDecrement) { - onDecrement(); - return; + e.preventDefault(); + onDecrement?.(); } - return false; - }, - Home: () => { + break; + case 'Home': if (onDecrementToMin) { - onDecrementToMin(); - return; + e.preventDefault(); + onDecrementToMin?.(); } - return false; - }, - End: () => { + break; + case 'End': if (onIncrementToMax) { - onIncrementToMax(); - return; + e.preventDefault(); + onIncrementToMax?.(); } - return false; - } + break; } - }); + }; let isFocused = useRef(false); let onFocus = () => { @@ -245,7 +244,7 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { 'aria-disabled': isDisabled || undefined, 'aria-readonly': isReadOnly || undefined, 'aria-required': isRequired || undefined, - ...keyboardProps, + onKeyDown, onFocus, onBlur }, diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 802fa7ff89a..ede27181d74 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -47,9 +47,21 @@ export function useStepListItem( const isSelected = selectedKey === key; + let onKeyDown = event => { + const {key: eventKey} = event; + + if (eventKey === 'ArrowDown' || eventKey === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + } + + itemProps.onKeyDown?.(event); + }; + return { stepProps: { ...itemProps, + onKeyDown, role: 'link', 'aria-current': isSelected ? 'step' : undefined, 'aria-disabled': isDisabled ? true : undefined, diff --git a/packages/react-aria/src/table/useTableColumnResize.ts b/packages/react-aria/src/table/useTableColumnResize.ts index 5dd4c15d431..dc0b1f02f5d 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -141,31 +141,20 @@ export function useTableColumnResize( [state, triggerRef, onResizeEnd] ); - let endResizeEvent = () => { - if (editModeEnabled) { - endResize(item); - return; - } - return false; - }; - let {keyboardProps} = useKeyboard({ - shortcuts: { - Escape: () => { - return endResizeEvent(); - }, - Enter: () => { - if (editModeEnabled) { + onKeyDown: e => { + if (editModeEnabled) { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { + e.preventDefault(); endResize(item); - } else { + } + } else { + // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there + e.continuePropagation(); + + if (e.key === 'Enter') { startResize(item); } - }, - ' ': () => { - return endResizeEvent(); - }, - Tab: () => { - return endResizeEvent(); } } }); diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index 84479e03603..c5b4ea5df25 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -15,6 +15,7 @@ import {DOMAttributes, FocusableElement, Node, RefObject} from '@react-types/sha import {filterDOMProps} from '../utils/filterDOMProps'; import {hookData} from './useTagGroup'; import intlMessages from '../../intl/tag/*.json'; +import {KeyboardEvent} from 'react'; import type {ListState} from 'react-stately/useListState'; import {mergeProps} from '../utils/mergeProps'; import {SelectableItemStates} from '../selection/useSelectableItem'; @@ -23,7 +24,6 @@ import {useFocusable} from '../interactions/useFocusable'; import {useGridListItem} from '../gridlist/useGridListItem'; import {useId} from '../utils/useId'; import {useInteractionModality} from '../interactions/useFocusVisible'; -import {useKeyboard} from '../interactions/useKeyboard'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useSyntheticLinkProps} from '../utils/openLink'; @@ -72,25 +72,20 @@ export function useTag( let {descriptionProps: _, ...stateWithoutDescription} = states; let isDisabled = state.disabledKeys.has(item.key) || item.props.isDisabled; - let {keyboardProps} = useKeyboard({ - isDisabled, - shortcuts: { - Delete: () => { - if (state.selectionManager.isSelected(item.key)) { - onRemove?.(new Set(state.selectionManager.selectedKeys)); - } else { - onRemove?.(new Set([item.key])); - } - }, - Backspace: () => { - if (state.selectionManager.isSelected(item.key)) { - onRemove?.(new Set(state.selectionManager.selectedKeys)); - } else { - onRemove?.(new Set([item.key])); - } + let onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Delete' || e.key === 'Backspace') { + if (isDisabled) { + return; + } + + e.preventDefault(); + if (state.selectionManager.isSelected(item.key)) { + onRemove?.(new Set(state.selectionManager.selectedKeys)); + } else { + onRemove?.(new Set([item.key])); } } - }); + }; let modality = useInteractionModality(); if (modality === 'virtual' && typeof window !== 'undefined' && 'ontouchstart' in window) { @@ -129,7 +124,7 @@ export function useTag( }, rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, { tabIndex, - ...(onRemove ? keyboardProps : {}), + onKeyDown: onRemove ? onKeyDown : undefined, 'aria-describedby': descProps['aria-describedby'] }), gridCellProps: mergeProps(gridCellProps, { diff --git a/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js b/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js index 23ac503b8a3..d732f5266e1 100644 --- a/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js +++ b/packages/react-aria/test/autocomplete/useSearchAutocomplete.test.js @@ -25,9 +25,7 @@ describe('useSearchAutocomplete', function () { isComposing: false }, preventDefault, - stopPropagation, - target: {}, - currentTarget: {contains: () => true} + stopPropagation }); let defaultProps = { diff --git a/packages/react-aria/test/combobox/useComboBox.test.js b/packages/react-aria/test/combobox/useComboBox.test.js index 62d2511adb4..389c7ec3e64 100644 --- a/packages/react-aria/test/combobox/useComboBox.test.js +++ b/packages/react-aria/test/combobox/useComboBox.test.js @@ -117,9 +117,7 @@ describe('useComboBox', function () { let {inputProps} = result.current; act(() => { - inputProps.onKeyDown( - event({key: 'Enter', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'Enter'})); }); expect(preventDefault).toHaveBeenCalledTimes(1); @@ -151,9 +149,7 @@ describe('useComboBox', function () { initialProps: props }); act(() => { - openResult.current.inputProps.onKeyDown( - event({key: 'Tab', target: {}, currentTarget: {contains: () => true}}) - ); + openResult.current.inputProps.onKeyDown(event({key: 'Tab'})); }); expect(commitSpy).toHaveBeenCalledTimes(1); }); @@ -165,15 +161,11 @@ describe('useComboBox', function () { let {result} = renderHook(props => useComboBox(props, state.current), {initialProps: props}); let {inputProps, buttonProps} = result.current; - inputProps.onKeyDown( - event({key: 'ArrowDown', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'ArrowDown'})); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenLastCalledWith('first', 'manual'); expect(toggleSpy).toHaveBeenCalledTimes(0); - inputProps.onKeyDown( - event({key: 'ArrowUp', target: {}, currentTarget: {contains: () => true}}) - ); + inputProps.onKeyDown(event({key: 'ArrowUp'})); expect(openSpy).toHaveBeenCalledTimes(2); expect(openSpy).toHaveBeenLastCalledWith('last', 'manual'); expect(toggleSpy).toHaveBeenCalledTimes(0); @@ -198,4 +190,36 @@ describe('useComboBox', function () { expect(onBlurMock).toHaveBeenCalledTimes(1); }); + + it.each` + Name | componentProps + ${'disabled'} | ${{isDisabled: true}} + ${'readonly'} | ${{isReadOnly: true}} + `( + "press and keyboard events on the button doesn't toggle the menu if $Name", + function ({componentProps}) { + let additionalProps = { + ...props, + ...componentProps + }; + + let {result: state} = renderHook(props => useComboBoxState(props), { + initialProps: additionalProps + }); + state.current.open = openSpy; + state.current.toggle = toggleSpy; + + let {result} = renderHook(props => useComboBox(props, state.current), { + initialProps: additionalProps + }); + let {buttonProps} = result.current; + buttonProps.onKeyDown(event({key: 'ArrowDown'})); + expect(openSpy).toHaveBeenCalledTimes(0); + expect(toggleSpy).toHaveBeenCalledTimes(0); + buttonProps.onKeyDown(event({key: 'ArrowUp'})); + expect(openSpy).toHaveBeenCalledTimes(0); + expect(toggleSpy).toHaveBeenCalledTimes(0); + expect(buttonProps.isDisabled).toBeTruthy(); + } + ); }); diff --git a/packages/react-aria/test/interactions/useKeyboard.test.js b/packages/react-aria/test/interactions/useKeyboard.test.js index 901b8bb74ab..60a0d405772 100644 --- a/packages/react-aria/test/interactions/useKeyboard.test.js +++ b/packages/react-aria/test/interactions/useKeyboard.test.js @@ -10,8 +10,6 @@ * governing permissions and limitations under the License. */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ - import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import {useKeyboard} from '../../src/interactions/useKeyboard'; @@ -99,280 +97,4 @@ describe('useKeyboard', function () { expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); }); - - describe('shortcuts', () => { - let platformMock; - let user; - beforeEach(() => { - user = userEvent.setup({delay: null, pointerMap}); - }); - afterEach(() => { - platformMock?.mockRestore(); - }); - let ExampleButton = props => { - let {keyboardProps} = useKeyboard(props); - return ; - }; - describe('Mac (Mod = Meta)', () => { - beforeEach(() => { - platformMock = jest - .spyOn(navigator, 'platform', 'get') - .mockImplementation(() => 'MacIntel'); - }); - - it('matches Mod+key with metaKey', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
- -
- ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); // Meta keydown should be only one - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); // both s keyup and meta keyup - - save.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - // None of the below should trigger the preventDefault and stopPropagation because - // we are not handling the event. - await user.keyboard('{Control>}s{/Control}'); - expect(save).not.toHaveBeenCalled(); - - await user.keyboard('s'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('plain key ignores meta', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
- -
- ); - - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); - }); - - it('Control+Shift distinct from Mod+Shift', async () => { - let modShift = jest.fn(); - let ctrlShift = jest.fn(); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - render( -
- -
- ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}{Shift>}a{/Shift}{/Meta}'); - expect(modShift).toHaveBeenCalledTimes(1); - expect(ctrlShift).not.toHaveBeenCalled(); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - modShift.mockClear(); - ctrlShift.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Control>}{Shift>}a{/Shift}{/Control}'); - expect(modShift).not.toHaveBeenCalled(); - expect(ctrlShift).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('Meta+Control+Alt combination', async () => { - let fn = jest.fn(); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - render( -
- -
- ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Meta>}{Control>}{Alt>}z{/Alt}{/Control}{/Meta}'); - expect(fn).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(4); - }); - - it('Shift+Alt and key aliases', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
- -
- ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Shift>}{Alt>}{ArrowDown}{/Alt}{/Shift}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - - it('Mod+Shift+a matches only that binding, not Mod+a', async () => { - let modA = jest.fn(() => true); - let modShiftA = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
- -
- ); - - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Shift>}{Meta>}a{/Meta}{/Shift}'); - expect(modShiftA).toHaveBeenCalled(); - expect(modA).not.toHaveBeenCalled(); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(2); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - modShiftA.mockClear(); - modA.mockClear(); - - await user.keyboard('{Meta>}a{/Meta}'); - expect(modA).toHaveBeenCalled(); - expect(modShiftA).not.toHaveBeenCalled(); - }); - - it('passes event to handler', async () => { - let fn = jest.fn(e => { - expect(e.key).toBe('Escape'); - }); - render(); - await user.tab(); - await user.keyboard('{Escape}'); - }); - - it('continues propagation if the function did not handle the event', async () => { - let fn = jest.fn(e => { - return false; - }); - let onWrapperKeyDown = jest.fn(e => { - expect(e.isDefaultPrevented()).toBe(false); - }); - let onWrapperKeyUp = jest.fn(); - render( -
- -
- ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - await user.keyboard('{Escape}'); - expect(fn).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - }); - - it('prevent default and stop propagation can both be finely controlled', async () => { - let fn = jest.fn(e => { - return {shouldPreventDefault: false, shouldContinuePropagation: true}; - }); - let onWrapperKeyDown = jest.fn(e => { - expect(e.isDefaultPrevented()).toBe(false); - }); - let onWrapperKeyUp = jest.fn(); - render( -
- -
- ); - await user.tab(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - await user.keyboard('{Escape}'); - expect(fn).toHaveBeenCalledTimes(1); - }); - }); - - describe('Windows (Mod = Ctrl)', () => { - beforeEach(() => { - platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Win32'); - }); - - it('matches Mod+key with ctrlKey', async () => { - let save = jest.fn(() => true); - let onWrapperKeyDown = jest.fn(); - let onWrapperKeyUp = jest.fn(); - - render( -
- -
- ); - - await user.tab(); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(1); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - await user.keyboard('{Control>}s{/Control}'); - expect(save).toHaveBeenCalledTimes(1); - expect(onWrapperKeyDown).toHaveBeenCalledTimes(1); // Meta keydown should be only one - expect(onWrapperKeyUp).toHaveBeenCalledTimes(2); // both s keyup and meta keyup - - save.mockClear(); - onWrapperKeyDown.mockClear(); - onWrapperKeyUp.mockClear(); - - // None of the below should trigger the preventDefault and stopPropagation because - // we are not handling the event. - await user.keyboard('{Meta>}s{/Meta}'); - expect(save).not.toHaveBeenCalled(); - - await user.keyboard('s'); - expect(save).not.toHaveBeenCalled(); - - expect(onWrapperKeyDown).toHaveBeenCalledTimes(3); - expect(onWrapperKeyUp).toHaveBeenCalledTimes(3); - }); - }); - }); }); diff --git a/packages/react-aria/test/searchfield/useSearchField.test.js b/packages/react-aria/test/searchfield/useSearchField.test.js index 3422c75175b..604711162f1 100644 --- a/packages/react-aria/test/searchfield/useSearchField.test.js +++ b/packages/react-aria/test/searchfield/useSearchField.test.js @@ -61,13 +61,8 @@ describe('useSearchField hook', () => { let onKeyDown = jest.fn(); let event = key => ({ key, - nativeEvent: { - isComposing: false - }, preventDefault, - stopPropagation, - target: {}, - currentTarget: {contains: () => true} + stopPropagation }); afterEach(() => {