diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 8646efb427c..85db6ee5cdc 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, getEventTarget, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, getEventTarget, isAppleDevice, isCtrlKeyPressed, isFocusWithin, isTabbable, mergeProps, nodeContains, scrollIntoView, scrollIntoViewport, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; @@ -137,7 +137,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. @@ -148,6 +155,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); } @@ -165,6 +173,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) { manager.replaceSelection(key); } + e.preventDefault(); } }; @@ -178,7 +187,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = delegate.getFirstKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } @@ -193,7 +201,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions nextKey = delegate.getLastKey?.(manager.focusedKey); } if (nextKey != null) { - e.preventDefault(); navigateToKey(nextKey); } } @@ -206,7 +213,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'); } } @@ -219,17 +225,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); @@ -243,10 +252,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); @@ -263,7 +276,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); } } @@ -272,18 +284,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(); @@ -291,6 +309,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. diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index 5cad37237cb..040a82bd6b8 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -2007,3 +2007,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 bc18c2d1ee7..fbeb3f2c2c3 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -288,6 +288,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(