diff --git a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx index c486c5d515b..e334b631149 100644 --- a/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx +++ b/packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx @@ -112,9 +112,8 @@ function ActionBarInner(props: ActionBarInnerProps, ref: Ref { onClearSelection(); } } diff --git a/packages/@react-spectrum/s2/src/ActionBar.tsx b/packages/@react-spectrum/s2/src/ActionBar.tsx index a108a1644ab..8b14d3262d6 100644 --- a/packages/@react-spectrum/s2/src/ActionBar.tsx +++ b/packages/@react-spectrum/s2/src/ActionBar.tsx @@ -168,12 +168,9 @@ const ActionBarInner = forwardRef(function ActionBarInner( }); let {keyboardProps} = useKeyboard({ - onKeyDown(e) { - if (e.key === 'Escape') { - e.preventDefault(); + shortcuts: { + Escape: () => { onClearSelection?.(); - } else { - e.continuePropagation(); } } }); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index b180804ff80..ef7319198a7 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2396,3 +2396,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/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index 15985ff3c46..8c35bd21cb3 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -25,10 +25,11 @@ import { import {createFocusManager} from '../focus/FocusScope'; import {filterDOMProps} from '../utils/filterDOMProps'; import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; -import {KeyboardEventHandler, useState} from 'react'; import {ListState} from 'react-stately/useListState'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useLayoutEffect} from '../utils/useLayoutEffect'; import {useLocale} from '../i18n/I18nProvider'; +import {useState} from 'react'; const BUTTON_GROUP_ROLES = { none: 'toolbar', @@ -91,34 +92,42 @@ export function useActionGroup( 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) { + }, + ArrowDown: e => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + focusManager.focusNext({wrap: true}); + }, + ArrowLeft: e => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + if (flipDirection) { focusManager.focusNext({wrap: true}); } else { focusManager.focusPrevious({wrap: true}); } - break; + }, + ArrowUp: e => { + if (!nodeContains(e.currentTarget, getEventTarget(e))) { + return false; + } + focusManager.focusPrevious({wrap: true}); + } } - }; + }); let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode]; if (isInToolbar && role === 'toolbar') { @@ -130,7 +139,7 @@ export function useActionGroup( 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 d610ab780ab..1a1efb20057 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 { /** @@ -78,70 +79,61 @@ export function useCalendarGrid( 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(); + }, + ' ': () => { + state.selectFocusedDate(); + }, + PageUp: () => { + state.focusPreviousSection(); + }, + 'Shift+PageUp': () => { + state.focusPreviousSection(true); + }, + PageDown: () => { + state.focusNextSection(); + }, + 'Shift+PageDown': () => { + state.focusNextSection(true); + }, + End: () => { state.focusSectionEnd(); - break; - case 'Home': - e.preventDefault(); - e.stopPropagation(); + }, + Home: () => { state.focusSectionStart(); - break; - case 'ArrowLeft': - e.preventDefault(); - e.stopPropagation(); + }, + ArrowLeft: () => { if (direction === 'rtl') { state.focusNextDay(); } else { state.focusPreviousDay(); } - break; - case 'ArrowUp': - e.preventDefault(); - e.stopPropagation(); + }, + ArrowUp: () => { state.focusPreviousRow(); - break; - case 'ArrowRight': - e.preventDefault(); - e.stopPropagation(); + }, + ArrowRight: () => { if (direction === 'rtl') { state.focusPreviousDay(); } else { state.focusNextDay(); } - break; - case 'ArrowDown': - e.preventDefault(); - e.stopPropagation(); + }, + ArrowDown: () => { state.focusNextRow(); - break; - case 'Escape': + }, + 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, @@ -182,7 +174,7 @@ export function useCalendarGrid( '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 0744f04a6a7..2d0434ada0b 100644 --- a/packages/react-aria/src/color/useColorArea.ts +++ b/packages/react-aria/src/color/useColorArea.ts @@ -112,46 +112,57 @@ 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': - state.incrementY(state.yChannelPageStep); - dir = 'y'; - break; - case 'PageDown': - state.decrementY(state.yChannelPageStep); - dir = 'y'; - break; - case 'Home': - direction === 'rtl' - ? state.incrementX(state.xChannelPageStep) - : state.decrementX(state.xChannelPageStep); - dir = 'x'; - break; - case 'End': - direction === 'rtl' - ? state.decrementX(state.xChannelPageStep) - : state.incrementX(state.xChannelPageStep); - dir = 'x'; - break; - } - state.setDragging(false); - if (dir) { - let input = dir === 'x' ? inputXRef : inputYRef; - focusInput(input); - setFocusedInput(dir); + shortcuts: { + PageUp: () => { + return keyboardUpdate( + () => { + state.incrementY(state.yChannelPageStep); + }, + inputYRef, + 'y' + ); + }, + PageDown: () => { + return keyboardUpdate( + () => { + state.decrementY(state.yChannelPageStep); + }, + inputYRef, + 'y' + ); + }, + Home: () => { + return keyboardUpdate( + () => { + direction === 'rtl' + ? state.incrementX(state.xChannelPageStep) + : state.decrementX(state.xChannelPageStep); + }, + inputXRef, + 'x' + ); + }, + End: () => { + return keyboardUpdate( + () => { + direction === 'rtl' + ? state.decrementX(state.xChannelPageStep) + : state.incrementX(state.xChannelPageStep); + }, + inputXRef, + 'x' + ); } } }); diff --git a/packages/react-aria/src/color/useColorWheel.ts b/packages/react-aria/src/color/useColorWheel.ts index 5a1edc2ca59..11a88e3c256 100644 --- a/packages/react-aria/src/color/useColorWheel.ts +++ b/packages/react-aria/src/color/useColorWheel.ts @@ -75,27 +75,19 @@ export function useColorWheel( 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; - } - // 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; + 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; } - state.setDragging(false); } }); diff --git a/packages/react-aria/src/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 78a2e9c03b0..346ee159db2 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -16,7 +16,6 @@ import {AriaButtonProps} from '../button/useButton'; import {ariaHideOutside} from '../overlays/ariaHideOutside'; import { AriaLabelingProps, - BaseEvent, DOMAttributes, DOMProps, InputDOMProps, @@ -34,7 +33,6 @@ import {dispatchVirtualFocus} from '../focus/virtualFocus'; import { FocusEvent, InputHTMLAttributes, - KeyboardEvent, TouchEvent, useEffect, useMemo, @@ -55,6 +53,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'; @@ -178,19 +177,15 @@ export function useComboBox( 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); @@ -198,7 +193,7 @@ export function useComboBox( 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, @@ -207,35 +202,80 @@ export function useComboBox( ); } state.close(); - break; + return {shouldPreventDefault}; } else if (collectionItem?.props.onAction) { collectionItem.props.onAction(); state.close(); - break; + return {shouldPreventDefault}; } } - if (e.key === 'Enter' || state.isOpen) { + 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(); + return {shouldPreventDefault: false}; + } + } + 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; @@ -279,7 +319,7 @@ export function useComboBox( : props.isRequired, onChange: state.setInputValue, onKeyDown: !isReadOnly - ? chain(state.isOpen && collectionProps.onKeyDown, onKeyDown, props.onKeyDown) + ? chain(state.isOpen && collectionProps.onKeyDown, keyboardProps.onKeyDown, props.onKeyDown) : props.onKeyDown, onBlur, value: state.inputValue, diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 11a23852686..5bc842bfc68 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -16,7 +16,6 @@ import { DOMProps, GroupDOMAttributes, InputDOMProps, - KeyboardEvent, RefObject, ValidationResult } from '@react-types/shared'; @@ -215,16 +214,8 @@ export function useDateField( } }, 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 3bf75fbe3df..b7173460bff 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'; @@ -17,26 +18,36 @@ export function useDatePickerGroup( 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; @@ -44,15 +55,23 @@ export function useDatePickerGroup( 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; @@ -60,14 +79,17 @@ export function useDatePickerGroup( 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 = () => { @@ -123,7 +145,7 @@ export function useDatePickerGroup( } }); - 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 0ecc714760f..8c4ec56bf44 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'; @@ -125,28 +126,23 @@ export function useDateSegment( } }; - 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'}); @@ -394,13 +390,14 @@ export function useDateSegment( segmentProps: mergeProps(spinButtonProps, labelProps, { id, ...touchPropOverrides, + ...keyboardProps, 'aria-invalid': state.isInvalid ? 'true' : undefined, 'aria-describedby': ariaDescribedBy, 'aria-readonly': state.isReadOnly || !segment.isEditable ? 'true' : undefined, 'data-placeholder': segment.isPlaceholder || undefined, contentEditable: isEditable, suppressContentEditableWarning: isEditable, - spellCheck: isEditable ? 'false' : 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 @@ -409,9 +406,8 @@ export function useDateSegment( inputMode: state.isDisabled || segment.type === 'dayPeriod' || segment.type === 'era' || !isEditable ? undefined - : 'numeric', + : ('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 88831ab4d9f..0c9d6900f9d 100644 --- a/packages/react-aria/src/interactions/createEventHandler.ts +++ b/packages/react-aria/src/interactions/createEventHandler.ts @@ -24,8 +24,8 @@ export function createEventHandler( return undefined; } - let shouldStopPropagation = true; return (e: T) => { + let shouldStopPropagation = true; let event: BaseEvent = { ...e, preventDefault() { @@ -53,7 +53,11 @@ export function createEventHandler( 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..644d0214133 --- /dev/null +++ b/packages/react-aria/src/interactions/createKeyboardShortcutHandler.ts @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {isMac} from '../utils/platform'; +import {KeyboardEvent} from '@react-types/shared'; + +export type KeyboardShortcutAction = ( + e: KeyboardEvent +) => + | void + | boolean + | Partial<{shouldContinuePropagation?: boolean; shouldPreventDefault?: boolean}>; + +/** Maps shortcut strings (e.g. `"Mod+s"`, `"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 +]); + +/** 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; + 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'); + } + 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 { + prev.key = part; + } + return prev; + }, + {shift: false, alt: false, ctrl: false, meta: false, mod: false, key: ''} + ); + if (parts.key === '') { + throw new Error( + `Invalid keyboard shortcut: "${spec}". Must include exactly one non-modifier key (e.g. "a", "Enter", "ArrowDown"). Combine any of Shift, Alt, Ctrl, Meta, and Mod.` + ); + } + return parts; +} + +function normalizeEventKey(key: string): string { + return key.toLowerCase(); +} + +/** Short aliases for common keys (shortcut side, before match). */ +const KEY_ALIASES: Record = { + space: ' ', + esc: 'escape', + del: 'delete', + ins: 'insert', + left: 'arrowleft', + right: 'arrowright', + up: 'arrowup', + down: 'arrowdown', + pageup: 'pageup', + pagedown: 'pagedown' +}; + +/** Canonical key segment (lowercase); aliases like `down` → `arrowdown`. */ +function canonicalKeyFromSpecKey(specKey: string): string { + let k = normalizeEventKey(specKey); + let aliased = KEY_ALIASES[k]; + return aliased != null ? aliased : k; +} + +/** Canonical shortcut string for a binding (modifiers sorted: Alt, Ctrl, Meta, Shift, then key). */ +export function canonicalKeyboardShortcut(parsed: ParsedKeyboardShortcut): string { + let mods = sortedModifierTokens(modifierSetFromParsed(parsed)); + let key = canonicalKeyFromSpecKey(parsed.key); + return mods.length > 0 ? `${mods.join('+')}+${key}` : key; +} + +/** Canonical shortcut string for a keydown event. */ +export function keyboardEventToCanonicalShortcut(e: KeyboardEvent): string { + let mods = sortedModifierTokens(modifierSetFromEvent(e)); + let key = normalizeEventKey(e.key); + let prefix = mods.length > 0 ? `${mods.join('+')}+` : ''; + return prefix + key; +} + +/** + * Returns a keydown handler that runs the action only for an exact modifier+key match. + * Modifier order in the string does not matter (`Shift+Mod+a` ≡ `Mod+Shift+a`). + * Any combination of **Shift**, **Alt**, **Ctrl**, **Meta**, and **Mod** is allowed; **Mod** means + * Cmd on Apple platforms and Ctrl on Windows/Linux (same as before). **control** aliases **ctrl**. + * + * Duplicate bindings that normalize to the same shortcut: later object entries win. + * + * @example + * ```tsx + * let onKeyDown = createKeyboardShortcutHandler({ + * 'Mod+s': e => { + * e.preventDefault(); + * save(); + * }, + * 'Ctrl+Shift+k': () => palette(), + * 'Meta+Alt+ArrowLeft': () => back() + * }); + * ```; + */ +export function createKeyboardShortcutHandler( + bindings: KeyboardShortcutBindings +): (e: KeyboardEvent) => void { + let map = new Map(); + for (let [spec, action] of Object.entries(bindings)) { + let parsed = parseKeyboardShortcut(spec); + map.set(canonicalKeyboardShortcut(parsed), action); + } + + return (e: KeyboardEvent) => { + let canonical = keyboardEventToCanonicalShortcut(e); + let action = map.get(canonical); + let result = action?.(e); + if (result === undefined && action !== undefined) { + result = {shouldContinuePropagation: false, shouldPreventDefault: true}; + } else if (typeof result === 'boolean') { + result = {shouldContinuePropagation: !result, shouldPreventDefault: result}; + } + if (result?.shouldPreventDefault) { + e.preventDefault(); + } + if (!action || result?.shouldContinuePropagation) { + e.continuePropagation(); + } + }; +} diff --git a/packages/react-aria/src/interactions/useKeyboard.ts b/packages/react-aria/src/interactions/useKeyboard.ts index f7f99ca827d..fdb49c57ec9 100644 --- a/packages/react-aria/src/interactions/useKeyboard.ts +++ b/packages/react-aria/src/interactions/useKeyboard.ts @@ -11,11 +11,21 @@ */ 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; + /** Keyboard shortcuts to handle. */ + shortcuts?: KeyboardShortcutBindings; + /** A ref to the element to ignore portal events. */ + ignorePortalRef?: RefObject | null; } export interface KeyboardResult { @@ -27,12 +37,48 @@ 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, what should the map be called? or should it be another syntax on shortcuts? + e.continuePropagation(); + props.onKeyUp?.(e); + }); + } else { + onKeyDown = createEventHandler(props.onKeyDown); + onKeyUp = createEventHandler(props.onKeyUp); + } return { keyboardProps: props.isDisabled ? {} : { - onKeyDown: 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 dbea1ac2b12..495c676ca04 100644 --- a/packages/react-aria/src/menu/useMenuItem.ts +++ b/packages/react-aria/src/menu/useMenuItem.ts @@ -314,44 +314,41 @@ export function useMenuItem( }); 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 13806dcc76a..13e5df4f1d7 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'; @@ -58,50 +59,54 @@ export function useMenuTrigger( 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({ @@ -146,8 +151,8 @@ export function useMenuTrigger( 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 21d6ffaf6ca..1a5ecfbc10f 100644 --- a/packages/react-aria/src/menu/useSubmenuTrigger.ts +++ b/packages/react-aria/src/menu/useSubmenuTrigger.ts @@ -13,14 +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, @@ -33,6 +26,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'; @@ -134,46 +128,51 @@ export function useSubmenuTrigger( }; }, [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, @@ -182,16 +181,15 @@ export function useSubmenuTrigger( ...(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'); } @@ -199,18 +197,19 @@ export function useSubmenuTrigger( 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'); } @@ -218,18 +217,18 @@ export function useSubmenuTrigger( 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')) { @@ -289,6 +288,7 @@ export function useSubmenuTrigger( return { submenuTriggerProps: { + ...(submenuTriggerKeyboardProps as any), // TODO: fix this id: submenuTriggerId, 'aria-controls': state.isOpen ? overlayId : undefined, 'aria-haspopup': !isDisabled ? type : undefined, @@ -296,7 +296,6 @@ export function useSubmenuTrigger( onPressStart, onPress, onHoverChange, - onKeyDown: submenuTriggerKeyDown, isOpen: state.isOpen }, submenuProps, diff --git a/packages/react-aria/src/numberfield/useNumberField.ts b/packages/react-aria/src/numberfield/useNumberField.ts index 25d933af25b..c3f9b3f6b06 100644 --- a/packages/react-aria/src/numberfield/useNumberField.ts +++ b/packages/react-aria/src/numberfield/useNumberField.ts @@ -22,7 +22,6 @@ import { TextInputDOMProps, ValidationResult } from '@react-types/shared'; -import {chain} from '../utils/chain'; import { type ClipboardEvent, type ClipboardEventHandler, @@ -47,6 +46,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'; @@ -257,28 +257,28 @@ export function useNumberField( }; let domProps = filterDOMProps(props); - let onKeyDownEnter = useCallback( - e => { - if (e.nativeEvent.isComposing) { - return; - } - - if (e.key === 'Enter') { + let {keyboardProps} = useKeyboard({ + isDisabled: isDisabled || isReadOnly, + shortcuts: { + Enter: e => { + if (e.nativeEvent.isComposing) { + return false; + } flushSync(() => { commit(); }); commitValidation(); - } else { - e.continuePropagation(); + return true; } }, - [commit, commitValidation] - ); + onKeyDown, + onKeyUp + }); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; let { labelProps, - inputProps: textFieldProps, + inputProps: textFieldPropsFromHook, descriptionProps, errorMessageProps } = useFormattedTextField( @@ -307,8 +307,6 @@ export function useNumberField( onBlur, onFocus, onFocusChange, - onKeyDown: useMemo(() => chain(onKeyDownEnter, onKeyDown), [onKeyDownEnter, onKeyDown]), - onKeyUp, onPaste, description, errorMessage @@ -317,6 +315,11 @@ export function useNumberField( inputRef ); + // Merge outside useFormattedTextField so useKeyboard's createEventHandler is not nested inside + // useTextField/useFocusable's createEventHandler (avoids redundant stopPropagation on RS events). + // Shortcuts run first (mergeProps chains the second argument after the first). + let textFieldProps = mergeProps(keyboardProps, textFieldPropsFromHook); + useFormReset(inputRef, state.defaultNumberValue, state.setNumberValue); useNativeValidation( state, diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1e844bf6427..a28d8ed85a1 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. */ @@ -125,13 +126,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({ @@ -167,7 +172,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' @@ -134,12 +109,36 @@ 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, { @@ -154,7 +153,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 c7b257b041c..8b9ea72c2e1 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 { @@ -64,38 +65,36 @@ export function useSearchField( let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/searchfield'); let {isDisabled, isReadOnly, onSubmit, onClear, 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,7 +115,8 @@ 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 @@ -124,11 +124,14 @@ export function useSearchField( return { labelProps, - inputProps: { + // 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 470a72555d3..809adbbf50e 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -34,6 +34,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'; @@ -136,16 +137,12 @@ export function useSelect( 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) @@ -153,12 +150,12 @@ export function useSelect( 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) @@ -166,10 +163,12 @@ export function useSelect( if (key != null) { state.setSelectedKey(key); } - break; + return true; } - } - }; + }, + onKeyDown: props.onKeyDown, + onKeyUp: props.onKeyUp + }); let {typeSelectProps} = useTypeSelect({ keyboardDelegate: delegate, @@ -221,8 +220,8 @@ export function useSelect( 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 4ecad56edbf..2c05a3b4281 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -19,10 +19,11 @@ import { 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 { @@ -35,11 +36,13 @@ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; import {isCtrlKeyPressed} from '../utils/keyboard'; +import {isMac} from '../utils/platform'; import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState'; import {scrollIntoView, scrollIntoViewport} from '../utils/scrollIntoView'; import {useEvent} from '../utils/useEvent'; +import {useKeyboard} from '../interactions/useKeyboard'; import {useLocale} from '../i18n/I18nProvider'; import {useRouter} from '../utils/openLink'; import {useTypeSelect} from './useTypeSelect'; @@ -159,233 +162,272 @@ export function useSelectableCollection( 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; + } + + return false; + } + + manager.setFocusedKey(key, childFocus); + + if (manager.isLink(key) && linkBehavior === 'override') { + return false; + } + + if (e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(key); + return; + } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { + manager.replaceSelection(key); + return; + } } + 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; } - 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); - } - - return; - } + 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; + }; - manager.setFocusedKey(key, childFocus); + 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; + }; - if (manager.isLink(key) && linkBehavior === 'override') { + 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; + } else if (selectOnFocus) { + manager.replaceSelection(firstKey); return; } + } + } + return false; + }; - if (e.shiftKey && manager.selectionMode === 'multiple') { - manager.extendSelection(key); - } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { - manager.replaceSelection(key); - } + 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; + }; - 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 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); } - case 'ArrowUp': { - if (delegate.getKeyAbove) { - let nextKey = - manager.focusedKey != null - ? delegate.getKeyAbove?.(manager.focusedKey) - : delegate.getLastKey?.(); - if (nextKey == null && shouldFocusWrap) { - nextKey = delegate.getLastKey?.(manager.focusedKey); - } - if (nextKey != null) { - e.preventDefault(); - navigateToKey(nextKey); - } - } - break; + if (nextKey != null) { + navigateToKey(e, nextKey, direction === 'rtl' ? 'last' : 'first'); + return {shouldPreventDefault: true, shouldContinuePropagation: true}; } - 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 end = (e: KeyboardEvent) => { + if (delegate.getLastKey) { + if (manager.focusedKey === null && e.shiftKey) { + return false; } - 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'); - } + let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); + manager.setFocusedKey(lastKey); + if (lastKey != null) { + if (isCtrlKeyPressed(e) && e.shiftKey && manager.selectionMode === 'multiple') { + manager.extendSelection(lastKey); + return; + } else if (selectOnFocus) { + manager.replaceSelection(lastKey); + return; } - break; } - 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; - // oxlint-disable-next-line max-depth - if (last) { - next = last; - } - } while (last); - - // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to - // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between - // focusing the containing cell and back to the non-tabbable child element - let activeElement = getActiveElement(); - if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { - focusWithoutScrolling(next); - } - } - break; + } + 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); + } + } + 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); + } + } + return false; + }; + + let aHandler = () => { + if (manager.selectionMode === 'multiple' && disallowSelectAll !== true) { + manager.selectAll(); + return; + } + return false; + }; + + let escape = () => { + if ( + escapeKeyBehavior === 'clearSelection' && + !disallowEmptySelection && + manager.selectedKeys.size !== 0 + ) { + manager.clearSelection(); + return; + } + return false; + }; + + let tab = () => { + if (!allowsTabNavigation && ref.current) { + // There may be elements that are "tabbable" inside a collection (e.g. in a grid cell). + // However, collections should be treated as a single tab stop, with arrow key navigation internally. + // We don't control the rendering of these, so we can't override the tabIndex to prevent tabbing. + // Instead, we handle the Tab key, and move focus manually to the first/last tabbable element + // in the collection, so that the browser default behavior will apply starting from that element + // rather than the currently focused one. + + let walker = getFocusableTreeWalker(ref.current, {tabbable: true}); + let next: FocusableElement | undefined = undefined; + let last: FocusableElement; + do { + last = walker.lastChild() as FocusableElement; + if (last) { + next = last; } + } while (last); + + // If the active element is NOT tabbable but is contained by an element that IS tabbable (aka the cell), the browser will actually move focus to + // the containing element. We need to special case this so that tab will move focus out of the grid instead of looping between + // focusing the containing cell and back to the non-tabbable child element + let activeElement = getActiveElement(); + if (next && (!isFocusWithin(next) || (activeElement && !isTabbable(activeElement)))) { + focusWithoutScrolling(next); } } + return {shouldContinuePropagation: true, shouldPreventDefault: false}; + }; + + let shiftTab = () => { + if (!allowsTabNavigation && ref.current) { + ref.current.focus(); + } + return {shouldContinuePropagation: true, shouldPreventDefault: false}; + }; + + let withShiftSel = (key, callback) => { + return { + [isMac() ? key + '+Shift+Alt' : key + '+Shift+Ctrl']: callback, + [key + '+Shift']: callback, + [isMac() ? key + '+Alt' : key + '+Ctrl']: 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), + [isMac() ? 'a+Alt' : 'a+Ctrl']: 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?? @@ -645,7 +687,7 @@ export function useSelectableCollection( }); 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 efaaa0b50f8..9fb7f5f0ecf 100644 --- a/packages/react-aria/src/slider/useSliderThumb.ts +++ b/packages/react-aria/src/slider/useSliderThumb.ts @@ -142,41 +142,36 @@ export function useSliderThumb(opts: AriaSliderThumbOptions, state: SliderState) let reverseX = direction === 'rtl'; let currentPosition = useRef(null); + let keyboardUpdate = cb => { + // remember to set this so that onChangeEnd is fired + state.setThumbDragging(index, true); + cb(); + state.setThumbDragging(index, false); + 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 32d06a93c8b..58782c3d2be 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'; const noop = () => {}; @@ -72,61 +73,78 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { 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 = () => { @@ -245,7 +263,7 @@ export function useSpinButton(props: SpinButtonProps): SpinbuttonAria { '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 ede27181d74..802fa7ff89a 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -47,21 +47,9 @@ export function useStepListItem( const isSelected = selectedKey === key; - let onKeyDown = event => { - const {key: eventKey} = event; - - if (eventKey === 'ArrowDown' || eventKey === 'ArrowUp') { - event.preventDefault(); - event.stopPropagation(); - } - - itemProps.onKeyDown?.(event); - }; - return { stepProps: { ...itemProps, - onKeyDown, role: 'link', 'aria-current': isSelected ? 'step' : undefined, 'aria-disabled': isDisabled ? true : undefined, diff --git a/packages/react-aria/src/table/useTableColumnResize.ts b/packages/react-aria/src/table/useTableColumnResize.ts index 4f52290c94f..11b0099e404 100644 --- a/packages/react-aria/src/table/useTableColumnResize.ts +++ b/packages/react-aria/src/table/useTableColumnResize.ts @@ -142,20 +142,33 @@ export function useTableColumnResize( [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 8f7f9143ffa..982979538df 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'; @@ -74,20 +74,27 @@ export function useTag( 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) { @@ -126,7 +133,7 @@ export function useTag( }, 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 389c7ec3e64..2f7df1178b5 100644 --- a/packages/react-aria/test/combobox/useComboBox.test.js +++ b/packages/react-aria/test/combobox/useComboBox.test.js @@ -190,36 +190,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 60a0d405772..9ce8e0b16cf 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'; @@ -97,4 +99,280 @@ 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); + }); + }); + }); });