diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 8f3326f95e8..e66cddda0bf 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2001,3 +2001,50 @@ 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>}{ArrowRight}{/Meta}'); + expect(document.activeElement).toBe(options[1]); + await user.keyboard('{Meta>}{ArrowLeft}{/Meta}'); + 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]); + await user.keyboard('{Control>}{End}{/Control}'); + expect(document.activeElement).toBe(options[1]); + await user.keyboard('{Meta>}{PageUp}{/Meta}'); + expect(document.activeElement).toBe(options[1]); + await user.keyboard('{Meta>}{PageDown}{/Meta}'); + expect(document.activeElement).toBe(options[1]); + }); + }); +}); diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index 20f255979ca..fa106d5db73 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -291,6 +291,30 @@ describe('Tabs', () => { expect(document.activeElement).toBe(items[2]); }); + it('should not navigate when using unsupported modifier keys', async () => { + let platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac'); + let {getAllByRole} = render( + + + A + B + C + + A + B + C + + ); + let items = getAllByRole('tab'); + expect(items[1]).toHaveAttribute('aria-disabled', 'true'); + + await user.tab(); + expect(document.activeElement).toBe(items[0]); + await user.keyboard('{Meta>}{ArrowRight}{/Meta}'); + expect(document.activeElement).toBe(items[0]); + platformMock.mockRestore(); + }); + it('finds the first non-disabled tab', async () => { let {getAllByRole} = render( diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 1628873baac..99d6014dfda 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -22,6 +22,7 @@ import {getActiveElement, getEventTarget, isFocusWithin, nodeContains} from '../ import {getFocusableTreeWalker} from '../focus/FocusScope'; import {getInteractionModality} from '../interactions/useFocusVisible'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; +import {isAppleDevice} from '../utils/platform'; import {isCtrlKeyPressed} from '../utils/keyboard'; import {isTabbable} from '../utils/isFocusable'; import {mergeProps} from '../utils/mergeProps'; @@ -149,7 +150,14 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions return; } + // uses shiftKey if selection mode is multiple + // if it's an apple device uses ctrlKey otherwise altKey const navigateToKey = (key: Key | undefined, childFocus?: FocusStrategy) => { + let shouldIgnoreModifierKeys = e.metaKey || (e.shiftKey && manager.selectionMode !== 'multiple') || (!isAppleDevice() ? e.altKey : e.ctrlKey); + if (shouldIgnoreModifierKeys) { + return; + } + 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. @@ -160,6 +168,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let item = getItemElement(ref, key); let itemProps = manager.getItemProps(key); if (item) { + e.preventDefault(); router.open(item, e, itemProps.href, itemProps.routerOptions); } @@ -177,6 +186,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { manager.replaceSelection(key); } + e.preventDefault(); } }; @@ -190,7 +200,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = delegate.getFirstKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } @@ -205,7 +214,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = delegate.getLastKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } @@ -218,7 +226,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = direction === 'rtl' ? delegate.getFirstKey?.(manager.focusedKey) : delegate.getLastKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); } } @@ -231,17 +238,20 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) : delegate.getFirstKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); } } break; } case 'Home': + if (e.altKey || (e.shiftKey && manager.selectionMode !== 'multiple') || (isAppleDevice() ? e.ctrlKey : e.metaKey)) { + return; + } if (delegate.getFirstKey) { if (manager.focusedKey === null && e.shiftKey) { return; } + e.stopPropagation(); e.preventDefault(); let firstKey: Key | null = delegate.getFirstKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(firstKey); @@ -255,10 +265,14 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } break; case 'End': + if (e.altKey || (e.shiftKey && manager.selectionMode !== 'multiple') || (isAppleDevice() ? e.ctrlKey : e.metaKey)) { + return; + } if (delegate.getLastKey) { if (manager.focusedKey === null && e.shiftKey) { return; } + e.stopPropagation(); e.preventDefault(); let lastKey = delegate.getLastKey(manager.focusedKey, isCtrlKeyPressed(e)); manager.setFocusedKey(lastKey); @@ -275,7 +289,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions if (delegate.getKeyPageBelow && manager.focusedKey != null) { let nextKey = delegate.getKeyPageBelow(manager.focusedKey); if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } @@ -284,18 +297,24 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions if (delegate.getKeyPageAbove && manager.focusedKey != null) { let nextKey = delegate.getKeyPageAbove(manager.focusedKey); if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } break; case 'a': + if (e.altKey || e.shiftKey || (isAppleDevice() ? e.ctrlKey : e.metaKey)) { + return; + } if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true) { + e.stopPropagation(); e.preventDefault(); manager.selectAll(); } break; case 'Escape': + if (e.altKey || e.shiftKey || e.metaKey || e.ctrlKey) { + return; + } if (escapeKeyBehavior === 'clearSelection' && !disallowEmptySelection && manager.selectedKeys.size !== 0) { e.stopPropagation(); e.preventDefault(); @@ -303,6 +322,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } break; case 'Tab': { + if (e.altKey || e.metaKey || e.ctrlKey) { + return; + } 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.