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(