diff --git a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx index 8122afe1342..e695dfa762b 100644 --- a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx +++ b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx @@ -105,10 +105,10 @@ function ActionBarInner(props: ActionBarInnerProps, ref: Ref { onClearSelection(); + return true; } } }); diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index ffc42fab4e9..2c0c196deaf 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -136,12 +136,10 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps }); let {keyboardProps} = useKeyboard({ - onKeyDown(e) { - if (e.key === 'Escape') { - e.preventDefault(); + shortcuts: { + 'Escape': () => { onClearSelection?.(); - } else { - e.continuePropagation(); + return true; } } }); diff --git a/packages/react-aria-components/test/Calendar.test.js b/packages/react-aria-components/test/Calendar.test.js index 32b61455c68..14fb9193266 100644 --- a/packages/react-aria-components/test/Calendar.test.js +++ b/packages/react-aria-components/test/Calendar.test.js @@ -201,8 +201,10 @@ describe('Calendar', () => { expect(grids).toHaveLength(2); let formatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'}); - expect(grids[0]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(new Date())); - expect(grids[1]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone()))); + let firstMonth = new CalendarDate(2026, 4, 1); + let tz = getLocalTimeZone(); + expect(grids[0]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(firstMonth.toDate(tz))); + expect(grids[1]).toHaveAttribute('aria-label', 'Appointment date, ' + formatter.format(firstMonth.add({months: 1}).toDate(tz))); let headings = container.querySelectorAll('.react-aria-CalendarHeading'); expect(headings).toHaveLength(2); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 49f4aa98c9b..2241eb94486 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2001,3 +2001,40 @@ 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-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index efda39b6d57..73948a1d98d 100644 --- a/packages/react-aria-components/test/RangeCalendar.test.tsx +++ b/packages/react-aria-components/test/RangeCalendar.test.tsx @@ -212,8 +212,10 @@ describe('RangeCalendar', () => { expect(grids).toHaveLength(2); let formatter = new Intl.DateTimeFormat('en-US', {month: 'long', year: 'numeric'}); - expect(grids[0]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(new Date())); - expect(grids[1]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(today(getLocalTimeZone()).add({months: 1}).toDate(getLocalTimeZone()))); + let firstMonth = new CalendarDate(2026, 4, 1); + let tz = getLocalTimeZone(); + expect(grids[0]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(firstMonth.toDate(tz))); + expect(grids[1]).toHaveAttribute('aria-label', 'Trip dates, ' + formatter.format(firstMonth.add({months: 1}).toDate(tz))); let headings = container.querySelectorAll('.react-aria-CalendarHeading'); expect(headings).toHaveLength(2); diff --git a/packages/react-aria/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index 18e3e95ff7a..ce1c7bed75c 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -14,10 +14,11 @@ import {AriaLabelingProps, DOMAttributes, DOMProps, FocusableElement, ItemElemen 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', @@ -75,34 +76,46 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta let {direction} = useLocale(); let focusManager = createFocusManager(ref); let flipDirection = direction === 'rtl' && orientation === 'horizontal'; - 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) { + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'ArrowRight': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + if (flipDirection) { focusManager.focusPrevious({wrap: true}); } else { focusManager.focusNext({wrap: true}); } - break; - case 'ArrowLeft': - case 'ArrowUp': - e.preventDefault(); - e.stopPropagation(); - if (e.key === 'ArrowLeft' && flipDirection) { + return true; + }, + 'ArrowDown': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + focusManager.focusNext({wrap: true}); + return true; + }, + 'ArrowLeft': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + if (flipDirection) { focusManager.focusNext({wrap: true}); } else { focusManager.focusPrevious({wrap: true}); } - break; + return true; + }, + 'ArrowUp': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + focusManager.focusPrevious({wrap: true}); + return true; + } } - }; + }); let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode]; if (isInToolbar && role === 'toolbar') { @@ -114,7 +127,7 @@ export function useActionGroup(props: AriaActionGroupProps, state: ListSta role, 'aria-orientation': role === 'toolbar' ? orientation : undefined, 'aria-disabled': isDisabled, - onKeyDown + ...keyboardProps } }; } diff --git a/packages/react-aria/src/calendar/useCalendarGrid.ts b/packages/react-aria/src/calendar/useCalendarGrid.ts index fd0c9e8e829..d3046612a35 100644 --- a/packages/react-aria/src/calendar/useCalendarGrid.ts +++ b/packages/react-aria/src/calendar/useCalendarGrid.ts @@ -14,12 +14,13 @@ 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 { /** @@ -71,70 +72,73 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta let {direction} = useLocale(); - let onKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'Enter': - case ' ': - e.preventDefault(); + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'Enter': () => { state.selectFocusedDate(); - 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(); + return true; + }, + ' ': () => { + state.selectFocusedDate(); + return true; + }, + 'PageUp': () => { + state.focusPreviousSection(); + return true; + }, + 'Shift+PageUp': () => { + state.focusPreviousSection(true); + return true; + }, + 'PageDown': () => { + state.focusNextSection(); + return true; + }, + 'Shift+PageDown': () => { + state.focusNextSection(true); + return true; + }, + 'End': () => { state.focusSectionEnd(); - break; - case 'Home': - e.preventDefault(); - e.stopPropagation(); + return true; + }, + 'Home': () => { state.focusSectionStart(); - break; - case 'ArrowLeft': - e.preventDefault(); - e.stopPropagation(); + return true; + }, + 'ArrowLeft': () => { if (direction === 'rtl') { state.focusNextDay(); } else { state.focusPreviousDay(); } - break; - case 'ArrowUp': - e.preventDefault(); - e.stopPropagation(); + return true; + }, + 'ArrowUp': () => { state.focusPreviousRow(); - break; - case 'ArrowRight': - e.preventDefault(); - e.stopPropagation(); + return true; + }, + 'ArrowRight': () => { if (direction === 'rtl') { state.focusPreviousDay(); } else { state.focusNextDay(); } - break; - case 'ArrowDown': - e.preventDefault(); - e.stopPropagation(); + return true; + }, + 'ArrowDown': () => { state.focusNextRow(); - break; - case 'Escape': + return true; + }, + 'Escape': () => { // Cancel the selection. if ('setAnchorDate' in state) { - e.preventDefault(); state.setAnchorDate(null); } - break; + return false; // TODO: is this really correct? or should it return true when we cancel and only propagate if there's nothing to do + } } - }; + }); let visibleRangeDescription = useVisibleRangeDescription(startDate, endDate, state.timeZone, true); @@ -164,7 +168,7 @@ export function useCalendarGrid(props: AriaCalendarGridProps, state: CalendarSta 'aria-readonly': state.isReadOnly || undefined, 'aria-disabled': state.isDisabled || undefined, 'aria-multiselectable': ('highlightedRange' in state) || state.selectionMode === 'multiple' || undefined, - onKeyDown, + ...keyboardProps, 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 e53f50fd4f4..2c6436f2bc2 100644 --- a/packages/react-aria/src/color/useColorArea.ts +++ b/packages/react-aria/src/color/useColorArea.ts @@ -106,42 +106,37 @@ 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); + return true; + }; + let {keyboardProps} = useKeyboard({ - 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': + shortcuts: { + 'PageUp': () => { + return keyboardUpdate(() => { state.incrementY(state.yChannelPageStep); - dir = 'y'; - break; - case 'PageDown': + }, inputYRef, 'y'); + }, + 'PageDown': () => { + return keyboardUpdate(() => { state.decrementY(state.yChannelPageStep); - dir = 'y'; - break; - case 'Home': + }, inputYRef, 'y'); + }, + 'Home': () => { + return keyboardUpdate(() => { direction === 'rtl' ? state.incrementX(state.xChannelPageStep) : state.decrementX(state.xChannelPageStep); - dir = 'x'; - break; - case 'End': + }, inputXRef, 'x'); + }, + 'End': () => { + return keyboardUpdate(() => { 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); + }, inputXRef, 'x'); } } }); diff --git a/packages/react-aria/src/color/useColorWheel.ts b/packages/react-aria/src/color/useColorWheel.ts index 48f571bf682..894a97d308c 100644 --- a/packages/react-aria/src/color/useColorWheel.ts +++ b/packages/react-aria/src/color/useColorWheel.ts @@ -71,27 +71,19 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta let currentPosition = useRef<{x: number, y: number} | null>(null); let {keyboardProps} = useKeyboard({ - onKeyDown(e) { - // these are the cases that useMove doesn't handle - if (!/^(PageUp|PageDown)$/.test(e.key)) { - e.continuePropagation(); - return; + shortcuts: { + 'PageUp': () => { + state.setDragging(true); + state.increment(state.pageStep); + state.setDragging(false); + return true; + }, + 'PageDown': () => { + state.setDragging(true); + state.decrement(state.pageStep); + state.setDragging(false); + return true; } - // 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 e712e347c6b..e6e05ababc1 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -14,12 +14,12 @@ import {announce} from '../live-announcer/LiveAnnouncer'; import {AriaButtonProps} from '../button/useButton'; import {ariaHideOutside} from '../overlays/ariaHideOutside'; -import {AriaLabelingProps, BaseEvent, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, DOMProps, InputDOMProps, KeyboardDelegate, LayoutDelegate, PressEvent, RefObject, RouterOptions, ValidationResult} from '@react-types/shared'; import {AriaListBoxOptions} from '../listbox/useListBox'; import {chain} from '../utils/chain'; import {ComboBoxProps, ComboBoxState, SelectionMode} from 'react-stately/useComboBoxState'; import {dispatchVirtualFocus} from '../focus/virtualFocus'; -import {FocusEvent, InputHTMLAttributes, KeyboardEvent, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; +import {FocusEvent, InputHTMLAttributes, TouchEvent, useEffect, useMemo, useRef, useState} from 'react'; import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getChildNodes} from 'react-stately/private/collections/getChildNodes'; import {getItemCount} from 'react-stately/private/collections/getItemCount'; @@ -34,6 +34,7 @@ import {useEvent} from '../utils/useEvent'; import {useFormReset} from '../utils/useFormReset'; import {useId} from '../utils/useId'; // @ts-ignore +import {useKeyboard} from '../interactions/useKeyboard'; import {useLabels} from '../utils/useLabels'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useMenuTrigger} from '../menu/useMenuTrigger'; @@ -145,61 +146,102 @@ export function useComboBox(props: AriaCo let router = useRouter(); - // 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(); + // for textfield specific operations + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'Enter': (e) => { + if (e.nativeEvent.isComposing) { + return false; } - + // Prevent default form submission if menu is open since we may be selecting a option + let shouldPreventDefault = state.isOpen; // 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) { let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`); - if (e.key === 'Enter' && item instanceof HTMLAnchorElement) { + if (item instanceof HTMLAnchorElement) { router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions); } state.close(); - break; + return {shouldPreventDefault}; + } else if (collectionItem?.props.onAction) { + collectionItem.props.onAction(); + state.close(); + return {shouldPreventDefault}; + } + } + state.commit(); + return {shouldPreventDefault}; + }, + 'Tab': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } + + // 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(); - break; + return {shouldPreventDefault: false}; } } - if (e.key === 'Enter' || state.isOpen) { + if (state.isOpen) { state.commit(); } - break; - case 'Escape': + return {shouldPreventDefault: false}; + }, + 'Escape': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } + let shouldContinuePropagation = false; if ( !state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue ) { - e.continuePropagation(); + shouldContinuePropagation = true; } state.revert(); - break; - case 'ArrowDown': + return {shouldContinuePropagation}; + }, + 'ArrowDown': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } state.open('first', 'manual'); - break; - case 'ArrowUp': + return true; + }, + 'ArrowUp': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } state.open('last', 'manual'); - break; - case 'ArrowLeft': - case 'ArrowRight': + return true; + }, + 'ArrowLeft': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } + state.selectionManager.setFocusedKey(null); + return true; + }, + 'ArrowRight': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } state.selectionManager.setFocusedKey(null); - break; + return true; + } } - }; + + }); let onBlur = (e: FocusEvent) => { let blurFromButton = buttonRef?.current && buttonRef.current === e.relatedTarget; @@ -235,7 +277,7 @@ export function useComboBox(props: AriaCo // In multi-select mode, only set required if the selection is empty. isRequired: props.selectionMode === 'multiple' ? props.isRequired && state.selectionManager.isEmpty : props.isRequired, onChange: state.setInputValue, - onKeyDown: !isReadOnly ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) : props.onKeyDown, + onKeyDown: !isReadOnly ? chain(state.isOpen && collectionProps.onKeyDown, keyboardProps.onKeyDown, props.onKeyDown) : props.onKeyDown, onBlur, value: state.inputValue, defaultValue: state.defaultInputValue, diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 06307d555bb..4394628d20b 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMAttributes, DOMProps, GroupDOMAttributes, InputDOMProps, KeyboardEvent, RefObject, ValidationResult} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, DOMProps, GroupDOMAttributes, InputDOMProps, RefObject, ValidationResult} from '@react-types/shared'; import {createFocusManager, FocusManager} from '../focus/FocusScope'; import {DateFieldProps, DateFieldState, DateValue} from 'react-stately/useDateFieldState'; import {filterDOMProps} from '../utils/filterDOMProps'; @@ -184,16 +184,8 @@ export function useDateField(props: AriaDateFieldOptions } }, fieldProps: mergeProps(domProps, fieldDOMProps, groupProps, focusWithinProps, { - onKeyDown(e: KeyboardEvent) { - if (props.onKeyDown) { - props.onKeyDown(e); - } - }, - onKeyUp(e: KeyboardEvent) { - if (props.onKeyUp) { - props.onKeyUp(e); - } - }, + onKeyDown: props.onKeyDown, + onKeyUp: props.onKeyUp, style: { unicodeBidi: 'isolate' } diff --git a/packages/react-aria/src/datepicker/useDatePickerGroup.ts b/packages/react-aria/src/datepicker/useDatePickerGroup.ts index f57c73790e8..cfd10a682af 100644 --- a/packages/react-aria/src/datepicker/useDatePickerGroup.ts +++ b/packages/react-aria/src/datepicker/useDatePickerGroup.ts @@ -2,9 +2,10 @@ 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, KeyboardEvent, RefObject} from '@react-types/shared'; +import {DOMAttributes, FocusableElement, 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'; @@ -13,26 +14,36 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState let {direction} = useLocale(); let focusManager = useMemo(() => createFocusManager(ref), [ref]); - // 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; - } + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'Alt+ArrowDown': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return false; + } + if ('setOpen' in state) { + state.setOpen(true); + return true; + } + return false; + }, + 'Alt+ArrowUp': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return false; + } + if ('setOpen' in state) { + state.setOpen(true); + return true; + } + return false; + }, + 'ArrowLeft': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return false; + } - switch (e.key) { - case 'ArrowLeft': - e.preventDefault(); - e.stopPropagation(); + if (disableArrowNavigation) { + return false; + } if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -40,15 +51,23 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState if (prev) { prev.focus(); + return true; } } } else { focusManager.focusPrevious(); + return true; + } + return false; + }, + 'ArrowRight': (e) => { + if (!nodeContains(e.currentTarget, getEventTarget(e) as Element)) { + return false; + } + + if (disableArrowNavigation) { + return false; } - break; - case 'ArrowRight': - e.preventDefault(); - e.stopPropagation(); if (direction === 'rtl') { if (ref.current) { let target = getEventTarget(e) as FocusableElement; @@ -56,14 +75,17 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState if (next) { next.focus(); + return true; } } } else { focusManager.focusNext(); + return true; } - break; + return false; + } } - }; + }); // Focus the first placeholder segment from the end on mouse down/touch up in the field. let focusLast = () => { @@ -119,7 +141,7 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState } }); - return mergeProps(pressProps, {onKeyDown}); + return mergeProps(pressProps, keyboardProps); } 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 90bae1cdbec..cfe08e158de 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, isMac} from '../utils/platform'; +import {isIOS} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import {NumberParser} from '@internationalized/number'; import React, {CSSProperties, useMemo, useRef} from 'react'; @@ -26,6 +26,7 @@ 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'; @@ -121,28 +122,23 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: } }; - 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(); + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'Backspace': () => { backspace(); - break; + return true; + }, + 'Delete': () => { + backspace(); + return true; + }, + 'Mod+a': () => { + // Firefox does not fire selectstart for Ctrl/Cmd + A + // https://bugzilla.mozilla.org/show_bug.cgi?id=1742153 + return true; } } - }; + }); // Safari dayPeriod option doesn't work... let {startsWith} = useFilter({sensitivity: 'base'}); @@ -383,19 +379,19 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: 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' : undefined, + spellCheck: isEditable ? 'false' as const : undefined, autoCorrect: isEditable ? 'off' : undefined, // Capitalization was changed in React 17... [parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: isEditable ? 'next' : undefined, - inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric', + inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined : 'numeric' as const, 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 fd9a9d27649..7cef6819a3c 100644 --- a/packages/react-aria/src/interactions/createEventHandler.ts +++ b/packages/react-aria/src/interactions/createEventHandler.ts @@ -21,8 +21,8 @@ export function createEventHandler(handler?: (e: BaseE return undefined; } - let shouldStopPropagation = true; return (e: T) => { + let shouldStopPropagation = true; let event: BaseEvent = { ...e, preventDefault() { @@ -48,7 +48,8 @@ export function createEventHandler(handler?: (e: BaseE handler(event); - if (shouldStopPropagation) { + // nested createEventHandler calls may already have stopped propagation + if (shouldStopPropagation && !(typeof e.isPropagationStopped === 'function' && e.isPropagationStopped())) { e.stopPropagation(); } }; diff --git a/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts b/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts new file mode 100644 index 00000000000..ed0b2d1cac8 --- /dev/null +++ b/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts @@ -0,0 +1,208 @@ +/* + * 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) => (boolean | Partial<{shouldContinuePropagation?: boolean, shouldPreventDefault?: boolean}>); + +/** Maps shortcut strings (e.g. `"Mod+s"`, `"Ctrl+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', + 'ctrl', + 'control', + 'meta', + 'mod', // OS dependent - Cmd on Mac, Ctrl on Windows/Linux + 'sel' // OS dependent - Alt on Mac, Ctrl on Windows/Linux +]); + +/** Canonical modifier order for stable keys (sorted, fixed order). */ +const CANONICAL_MODIFIER_ORDER = ['Alt', 'Ctrl', 'Meta', 'Shift'] as const; + +export interface ParsedKeyboardShortcut { + shift: boolean, + alt: boolean, + ctrl: boolean, + meta: boolean, + /** Platform primary: Cmd on Mac, Ctrl on Windows/Linux — expands to Meta or Ctrl in canonical form. */ + mod: boolean, + /** Platform secondary: Alt on Mac, Ctrl on Windows/Linux. */ + sel: 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('Ctrl'); + } + if (parsed.meta) { + set.add('Meta'); + } + if (parsed.mod) { + set.add(isMac() ? 'Meta' : 'Ctrl'); + } + if (parsed.sel) { // Todo: I think there was a conflict or difference in behaviour in the original code based on this. + set.add(isMac() ? 'Alt' : 'Ctrl'); + } + 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('Ctrl'); + } + 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 === 'ctrl' || lower === 'control') { + prev.ctrl = true; + } else if (lower === 'meta') { + prev.meta = true; + } else if (lower === 'mod') { + prev.mod = true; + } else if (lower === 'sel') { + prev.sel = true; + } + } else { + prev.key = part; + } + return prev; + }, {shift: false, alt: false, ctrl: false, meta: false, mod: false, sel: 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 (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 81ba095452d..df69ac5b7fa 100644 --- a/packages/react-aria/src/interactions/useKeyboard.ts +++ b/packages/react-aria/src/interactions/useKeyboard.ts @@ -11,11 +11,18 @@ */ 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, RefObject} from 'react'; export interface KeyboardProps extends KeyboardEvents { /** Whether the keyboard events should be disabled. */ - isDisabled?: boolean + isDisabled?: boolean, + /** Keyboard shortcuts to handle. */ + shortcuts?: KeyboardShortcutBindings, + /** A ref to the element to ignore portal events. */ + ignorePortalRef?: RefObject | null } export interface KeyboardResult { @@ -27,10 +34,38 @@ export interface KeyboardResult { * Handles keyboard interactions for a focusable element. */ export function useKeyboard(props: KeyboardProps): KeyboardResult { + let {shortcuts, ignorePortalRef = null} = props; + let onKeyDown; + let onKeyUp; + if (shortcuts) { + let shortcutHandler = createKeyboardShortcutHandler(shortcuts); + onKeyDown = createEventHandler>((e) => { + // should be built in more somehow? or turn it off per matched handler? + + if (ignorePortalRef && ignorePortalRef.current && !nodeContains(ignorePortalRef.current, getEventTarget(e) as Element)) { + e.continuePropagation(); + return; + } + shortcutHandler(e); + props.onKeyDown?.(e); + }); + onKeyUp = createEventHandler>((e) => { + if (ignorePortalRef && ignorePortalRef.current && !nodeContains(ignorePortalRef.current, getEventTarget(e) as Element)) { + e.continuePropagation(); + return; + } + // implement shortcut handler on keyup + e.continuePropagation(); + props.onKeyUp?.(e); + }); + } else { + onKeyDown = createEventHandler(props.onKeyDown); + onKeyUp = createEventHandler(props.onKeyUp); + } return { keyboardProps: props.isDisabled ? {} : { - onKeyDown: createEventHandler(props.onKeyDown), - onKeyUp: createEventHandler(props.onKeyUp) + onKeyDown, + onKeyUp } }; } diff --git a/packages/react-aria/src/menu/useMenuItem.ts b/packages/react-aria/src/menu/useMenuItem.ts index 0133b5ca7b6..9cf5ee07dab 100644 --- a/packages/react-aria/src/menu/useMenuItem.ts +++ b/packages/react-aria/src/menu/useMenuItem.ts @@ -286,44 +286,41 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re }); let {keyboardProps} = useKeyboard({ - 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. + shortcuts: { + ' ': (e) => { + if (e.repeat) { + return false; + } + 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 true; + }, + 'Enter': (e) => { + if (e.repeat) { + return false; + } + 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(); setInteractionModality('keyboard'); - break; - case 'Enter': - interaction.current = {pointerType: 'keyboard', key: 'Enter'}; + return true; + } - // 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; + setInteractionModality('keyboard'); + return {shouldPreventDefault: false, shouldContinuePropagation: false}; } }, + onKeyDown, onKeyUp }); diff --git a/packages/react-aria/src/menu/useMenuTrigger.ts b/packages/react-aria/src/menu/useMenuTrigger.ts index 917dc5cecc8..75b9297e8ff 100644 --- a/packages/react-aria/src/menu/useMenuTrigger.ts +++ b/packages/react-aria/src/menu/useMenuTrigger.ts @@ -13,13 +13,14 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaMenuOptions} from './useMenu'; -import {FocusableElement, RefObject} from '@react-types/shared'; +import {FocusableElement, FocusStrategy, KeyboardEvent, RefObject} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import intlMessages from '../../intl/menu/*.json'; // @ts-ignore 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,50 +58,50 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge let menuTriggerId = useId(); let {triggerProps, overlayProps} = useOverlayTrigger({type}, state, ref); - let onKeyDown = (e) => { - if (isDisabled) { - return; - } - - if (trigger === 'longPress' && !e.altKey) { - return; + let open = (shouldOpen: boolean, e: KeyboardEvent, focusStrategy: FocusStrategy = 'first'): boolean => { + if (!shouldOpen || e.isDefaultPrevented()) { + return false; } + state.toggle(focusStrategy); + return true; + }; - 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(); - } + // 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'); } } - }; + }); let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/menu'); let {longPressProps} = useLongPress({ @@ -145,8 +146,8 @@ export function useMenuTrigger(props: AriaMenuTriggerProps, state: MenuTrigge menuTriggerProps: { ...triggerProps, ...(trigger === 'press' ? pressProps : longPressProps), - id: menuTriggerId, - onKeyDown + ...keyboardProps, + id: menuTriggerId }, menuProps: { ...overlayProps, diff --git a/packages/react-aria/src/menu/useSubmenuTrigger.ts b/packages/react-aria/src/menu/useSubmenuTrigger.ts index 48c7fae457b..cee3d4d7a9f 100644 --- a/packages/react-aria/src/menu/useSubmenuTrigger.ts +++ b/packages/react-aria/src/menu/useSubmenuTrigger.ts @@ -13,7 +13,7 @@ import {AriaMenuItemProps} from './useMenuItem'; import {AriaMenuOptions} from './useMenu'; import type {AriaPopoverProps} from '../overlays/usePopover'; -import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared'; +import {FocusableElement, FocusStrategy, Node, PressEvent, RefObject} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import {getActiveElement, getEventTarget, isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; import type {OverlayProps} from '../overlays/Overlay'; @@ -21,6 +21,7 @@ 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'; @@ -102,46 +103,51 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm }; }, [cancelOpenTimeout]); - 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': + 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; + } if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) { - e.preventDefault(); - e.stopPropagation(); onSubmenuClose(); if (!shouldUseVirtualFocus && ref.current) { focusWithoutScrolling(ref.current); } + return true; + } + 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 true; + } + 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 true; } - break; + return false; + } } - }; + }); let submenuProps = { id: overlayId, @@ -150,16 +156,15 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm ...(type === 'menu' && { onClose: state.closeAll, autoFocus: state.focusStrategy ?? undefined, - onKeyDown: submenuKeyDown + ...keyboardProps }) }; - let submenuTriggerKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowRight': + let {keyboardProps: submenuTriggerKeyboardProps} = useKeyboard({ + shortcuts: { + 'ArrowRight': () => { if (!isDisabled) { if (direction === 'ltr') { - e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -167,18 +172,19 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } + return true; } else if (state.isOpen) { onSubmenuClose(); + return true; } else { - e.continuePropagation(); + return false; } } - - break; - case 'ArrowLeft': + return false; + }, + 'ArrowLeft': () => { if (!isDisabled) { if (direction === 'rtl') { - e.preventDefault(); if (!state.isOpen) { onSubmenuOpen('first'); } @@ -186,18 +192,18 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) { focusWithoutScrolling(submenuRef.current); } + return true; } else if (state.isOpen) { onSubmenuClose(); + return true; } else { - e.continuePropagation(); + return false; } } - break; - default: - e.continuePropagation(); - break; + return false; + } } - }; + }); let onPressStart = (e: PressEvent) => { if (!isDisabled && (e.pointerType === 'virtual' || e.pointerType === 'keyboard')) { @@ -248,6 +254,7 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm return { submenuTriggerProps: { + ...submenuTriggerKeyboardProps as any, // TODO: fix this id: submenuTriggerId, 'aria-controls': state.isOpen ? overlayId : undefined, 'aria-haspopup': !isDisabled ? type : undefined, @@ -255,7 +262,6 @@ export function useSubmenuTrigger(props: AriaSubmenuTriggerProps, state: Subm 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 cc5897ca799..26d0dee0942 100644 --- a/packages/react-aria/src/numberfield/useNumberField.ts +++ b/packages/react-aria/src/numberfield/useNumberField.ts @@ -14,7 +14,6 @@ import {announce} from '../live-announcer/LiveAnnouncer'; import {AriaButtonProps} from '../button/useButton'; import {AriaLabelingProps, DOMAttributes, DOMProps, GroupDOMAttributes, TextInputDOMEvents, TextInputDOMProps, ValidationResult} from '@react-types/shared'; -import {chain} from '../utils/chain'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -39,6 +38,7 @@ 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'; @@ -231,23 +231,26 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt }; let domProps = filterDOMProps(props); - let onKeyDownEnter = useCallback((e) => { - if (e.nativeEvent.isComposing) { - return; - } - - if (e.key === 'Enter') { - flushSync(() => { - commit(); - }); - commitValidation(); - } else { - e.continuePropagation(); - } - }, [commit, commitValidation]); + let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, + shortcuts: { + 'Enter': (e) => { + if (e.nativeEvent.isComposing) { + return false; + } + flushSync(() => { + commit(); + }); + commitValidation(); + return true; + } + }, + onKeyDown, + onKeyUp + }); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps} = useFormattedTextField({ + let {labelProps, inputProps: textFieldPropsFromHook, descriptionProps, errorMessageProps} = useFormattedTextField({ ...otherProps, ...domProps, // These props are added to a hidden input rather than the formatted textfield. @@ -272,13 +275,16 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt onBlur, onFocus, onFocusChange, - onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), - onKeyUp, onPaste, description, errorMessage }, state, 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, props.validationBehavior, props.commitBehavior, inputRef, state.minValue, state.maxValue, props.step, state.numberValue); diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index e660df06116..d420e41e45e 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -16,6 +16,7 @@ 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. */ @@ -117,13 +118,17 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (e.key === 'Escape' && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { - e.stopPropagation(); - e.preventDefault(); - onHide(); + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'Escape': (e) => { + if (!isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { + onHide(); + return true; + } + return false; + } } - }; + }); // Handle clicking outside the overlay to close it useInteractOutside({ref, onInteractOutside: isDismissable && isOpen ? onInteractOutside : undefined, onInteractOutsideStart}); @@ -152,7 +157,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(); + function getNextElement(nextDir: 'next' | 'prev', e) { let walker = getFocusableTreeWalker(e.currentTarget, { from: getEventTarget(e) as Element, accept: (node) => node instanceof getOwnerWindow(node).HTMLInputElement && node.type === 'radio' @@ -125,12 +100,34 @@ 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, { @@ -145,7 +142,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState radioGroupProps: mergeProps(domProps, { // https://www.w3.org/TR/wai-aria-1.2/#radiogroup role: 'radiogroup', - onKeyDown, + ...keyboardProps, '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 a5e076a27f9..d44be77cd14 100644 --- a/packages/react-aria/src/searchfield/useSearchField.ts +++ b/packages/react-aria/src/searchfield/useSearchField.ts @@ -12,12 +12,13 @@ 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'; -// @ts-ignore 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,38 +66,36 @@ export function useSearchField( type = 'search' } = props; - 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(''); - if (onClear) { - onClear(); + let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, + shortcuts: { + 'Enter': () => { + if (isDisabled || isReadOnly) { + return true; + } else if (onSubmit) { + // for backward compatibility; + // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior + onSubmit(state.value); + return true; + } + return false; + }, + 'Escape': () => { + if (isDisabled || isReadOnly) { + return false; + } + // 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; + } else { + state.setValue(''); + onClear?.(); + return true; } } } - }; + }); let onClearButtonClick = () => { state.setValue(''); @@ -116,17 +115,24 @@ export function useSearchField( ...props, value: state.value, onChange: state.setValue, - onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, + onKeyDown: props.onKeyDown, + onKeyUp: props.onKeyUp, type }, inputRef); return { labelProps, - inputProps: { - ...inputProps, - // already handled by useSearchFieldState - defaultValue: undefined - }, + // 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, + // 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 faec979cf3a..663b070a3b4 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -26,6 +26,7 @@ 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'; @@ -117,34 +118,32 @@ export function useSelect(props: AriaSele ref ); - let onKeyDown = (e: KeyboardEvent) => { - if (state.selectionManager.selectionMode === 'multiple') { - return; - } - - switch (e.key) { - case 'ArrowLeft': { - // prevent scrolling containers - e.preventDefault(); - + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'ArrowLeft': () => { + if (state.selectionManager.selectionMode === 'multiple') { + return false; + } let key = state.selectedKey != null ? delegate.getKeyAbove?.(state.selectedKey) : delegate.getFirstKey?.(); if (key != null) { state.setSelectedKey(key); } - break; - } - case 'ArrowRight': { - // prevent scrolling containers - e.preventDefault(); - + return true; + }, + 'ArrowRight': () => { + if (state.selectionManager.selectionMode === 'multiple') { + return false; + } let key = state.selectedKey != null ? delegate.getKeyBelow?.(state.selectedKey) : delegate.getFirstKey?.(); if (key != null) { state.setSelectedKey(key); } - break; + return true; } - } - }; + }, + onKeyDown: props.onKeyDown, + onKeyUp: props.onKeyUp + }); let {typeSelectProps} = useTypeSelect({ keyboardDelegate: delegate, @@ -196,8 +195,8 @@ export function useSelect(props: AriaSele triggerProps: mergeProps(domProps, { ...triggerProps, isDisabled, - onKeyDown: chain(triggerProps.onKeyDown, onKeyDown, props.onKeyDown), - onKeyUp: props.onKeyUp, + onKeyDown: chain(triggerProps.onKeyDown, keyboardProps.onKeyDown), + onKeyUp: keyboardProps.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 799e0e5b9a5..b6249881664 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -13,9 +13,9 @@ import {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from '../utils/constants'; import {dispatchVirtualFocus, moveVirtualFocus} from '../focus/virtualFocus'; -import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; +import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, KeyboardEvent, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; -import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; +import {FocusEvent, useEffect, useRef} from 'react'; import {focusSafely} from '../interactions/focusSafely'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; import {getActiveElement, getEventTarget, isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; @@ -28,6 +28,7 @@ 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'; @@ -137,206 +138,248 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let {direction} = useLocale(); let router = useRouter(); - 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(); + + const navigateToKey = (e: KeyboardEvent, key: Key | undefined, childFocus?: FocusStrategy): boolean => { + 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 true; + } + + return false; + } + + manager.setFocusedKey(key, childFocus); + + if (manager.isLink(key) && linkBehavior === 'override') { + return false; + } + + if (e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(key); + return true; + } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { + manager.replaceSelection(key); + return true; + } } + return false; + }; + let arrowDown = (e: KeyboardEvent) => { // 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; + } + + 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}; + } } + 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 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; + }; - return; + let home = (e: KeyboardEvent) => { + if (delegate.getFirstKey) { + if (manager.focusedKey === null && e.shiftKey) { + return false; + } + 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 true; + } else if (selectOnFocus) { + manager.replaceSelection(firstKey); + return true; } + } + } + return false; + }; - manager.setFocusedKey(key, childFocus); - - if (manager.isLink(key) && linkBehavior === 'override') { - return; - } + 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; + }; - if (e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(key); - } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { - manager.replaceSelection(key); - } + 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; + }; - 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; + let end = (e: KeyboardEvent) => { + if (delegate.getLastKey) { + if (manager.focusedKey === null && e.shiftKey) { + return false; } - 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); - } + 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 true; + } else if (selectOnFocus) { + manager.replaceSelection(lastKey); + return true; } - 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; + } + 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); } - 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 pageUp = (e: KeyboardEvent) => { + if (delegate.getKeyPageAbove && manager.focusedKey != null) { + let nextKey = delegate.getKeyPageAbove(manager.focusedKey); + if (nextKey != null) { + return navigateToKey(e, nextKey); } - case 'Home': - if (delegate.getFirstKey) { - if (manager.focusedKey === null && e.shiftKey) { - return; - } - 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; - 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; + } + return false; + }; + + let aHandler = () => { + if (manager.selectionMode === 'multiple' && disallowSelectAll !== true) { + manager.selectAll(); + return true; + } + return false; + }; + + let escape = () => { + if (escapeKeyBehavior === 'clearSelection' && !disallowEmptySelection && manager.selectedKeys.size !== 0) { + manager.clearSelection(); + return true; + } + 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; } + } 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 { + [key + '+Shift+Sel']: callback, + [key + '+Shift']: callback, + [key + '+Sel']: callback, + [key]: callback + }; + }; + let {keyboardProps} = useKeyboard({ + ignorePortalRef: ref as RefObject, + 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), + 'a+Sel': 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?? let scrollPos = useRef({top: 0, left: 0}); @@ -568,7 +611,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions }); let handlers = { - onKeyDown, + ...keyboardProps, onFocus, onBlur, onMouseDown(e) { diff --git a/packages/react-aria/src/slider/useSliderThumb.ts b/packages/react-aria/src/slider/useSliderThumb.ts index effd6a3f365..5fee671c9b4 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -117,41 +117,36 @@ export function useSliderThumb( 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); + return true; + }; + let {keyboardProps} = useKeyboard({ - 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; + 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)); + }); } - setThumbDragging(index, false); } }); diff --git a/packages/react-aria/src/spinbutton/useSpinButton.ts b/packages/react-aria/src/spinbutton/useSpinButton.ts index 2490a1070e4..80ff20401c2 100644 --- a/packages/react-aria/src/spinbutton/useSpinButton.ts +++ b/packages/react-aria/src/spinbutton/useSpinButton.ts @@ -19,6 +19,7 @@ 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'; @@ -74,54 +75,78 @@ export function useSpinButton( return () => clearAsyncEvent(); }, []); - let onKeyDown = (e) => { - if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || isReadOnly || e.nativeEvent.isComposing) { - return; - } - - switch (e.key) { - case 'PageUp': + let {keyboardProps} = useKeyboard({ + shortcuts: { + 'PageUp': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; + } if (onIncrementPage) { - e.preventDefault(); - onIncrementPage?.(); - break; + onIncrementPage(); + return true; } - // fallthrough! - case 'ArrowUp': - case 'Up': if (onIncrement) { - e.preventDefault(); - onIncrement?.(); + onIncrement(); + return true; + } + return false; + }, + 'ArrowUp': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; + } + if (onIncrement) { + onIncrement(); + return true; + } + return false; + }, + 'PageDown': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; } - break; - case 'PageDown': if (onDecrementPage) { - e.preventDefault(); - onDecrementPage?.(); - break; + onDecrementPage(); + return true; } - // fallthrough - case 'ArrowDown': - case 'Down': if (onDecrement) { - e.preventDefault(); - onDecrement?.(); + onDecrement(); + return true; + } + return false; + }, + 'ArrowDown': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; + } + if (onDecrement) { + onDecrement(); + return true; + } + return false; + }, + 'Home': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; } - break; - case 'Home': if (onDecrementToMin) { - e.preventDefault(); - onDecrementToMin?.(); + onDecrementToMin(); + return true; + } + return false; + }, + 'End': (e) => { + if (isReadOnly || e.nativeEvent.isComposing) { + return false; } - break; - case 'End': if (onIncrementToMax) { - e.preventDefault(); - onIncrementToMax?.(); + onIncrementToMax(); + return true; } - break; + return false; + } } - }; + }); let isFocused = useRef(false); let onFocus = () => { @@ -221,7 +246,7 @@ export function useSpinButton( 'aria-disabled': isDisabled || undefined, 'aria-readonly': isReadOnly || undefined, 'aria-required': isRequired || undefined, - onKeyDown, + ...keyboardProps, onFocus, onBlur }, diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index a9284a824a3..be16e4420ee 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -43,21 +43,9 @@ export function useStepListItem(props: AriaStepListItemProps, state: StepList 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 ef54633c57b..380513cf383 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -115,20 +115,33 @@ export function useTableColumnResize(props: AriaTableColumnResizeProps, st lastSize.current = null; }, [state, triggerRef, onResizeEnd]); + let endResizeEvent = () => { + if (editModeEnabled) { + endResize(item); + return true; + } + return false; + }; + let {keyboardProps} = useKeyboard({ - onKeyDown: (e) => { - if (editModeEnabled) { - if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') { - e.preventDefault(); + shortcuts: { + 'Escape': () => { + return endResizeEvent(); + }, + 'Enter': () => { + if (editModeEnabled) { endResize(item); - } - } else { - // Continue propagation on keydown events so they still bubbles to useSelectableCollection and are handled there - e.continuePropagation(); - - if (e.key === 'Enter') { + return true; + } else { startResize(item); + return true; } + }, + ' ': () => { + return endResizeEvent(); + }, + 'Tab': () => { + return endResizeEvent(); } } }); diff --git a/packages/react-aria/src/tag/useTag.ts b/packages/react-aria/src/tag/useTag.ts index f1599540312..66c1abe902d 100644 --- a/packages/react-aria/src/tag/useTag.ts +++ b/packages/react-aria/src/tag/useTag.ts @@ -16,7 +16,6 @@ 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'; // @ts-ignore @@ -26,6 +25,7 @@ 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'; @@ -66,20 +66,27 @@ export function useTag(props: AriaTagProps, state: ListState, ref: RefO let {descriptionProps: _, ...stateWithoutDescription} = states; let isDisabled = state.disabledKeys.has(item.key) || item.props.isDisabled; - 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 {keyboardProps} = useKeyboard({ + isDisabled, + shortcuts: { + 'Delete': () => { + if (state.selectionManager.isSelected(item.key)) { + onRemove?.(new Set(state.selectionManager.selectedKeys)); + } else { + onRemove?.(new Set([item.key])); + } + return true; + }, + 'Backspace': () => { + if (state.selectionManager.isSelected(item.key)) { + onRemove?.(new Set(state.selectionManager.selectedKeys)); + } else { + onRemove?.(new Set([item.key])); + } + return true; } } - }; + }); let modality = useInteractionModality(); if (modality === 'virtual' && (typeof window !== 'undefined' && 'ontouchstart' in window)) { @@ -112,7 +119,7 @@ export function useTag(props: AriaTagProps, state: ListState, ref: RefO }, rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, { tabIndex, - onKeyDown: onRemove ? onKeyDown : undefined, + ...(onRemove ? keyboardProps : {}), 'aria-describedby': descProps['aria-describedby'] }), gridCellProps: mergeProps(gridCellProps, { diff --git a/packages/react-aria/test/combobox/useComboBox.test.js b/packages/react-aria/test/combobox/useComboBox.test.js index ac656129b7d..df7153ce8f7 100644 --- a/packages/react-aria/test/combobox/useComboBox.test.js +++ b/packages/react-aria/test/combobox/useComboBox.test.js @@ -172,29 +172,4 @@ 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 2e918c99477..5cce0ab8807 100644 --- a/packages/react-aria/test/interactions/useKeyboard.test.js +++ b/packages/react-aria/test/interactions/useKeyboard.test.js @@ -10,6 +10,8 @@ * 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'; @@ -106,4 +108,283 @@ 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('Ctrl+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+Ctrl+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); + }); + }); + }); });