Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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);
}

Expand All @@ -165,6 +173,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
} else if (selectOnFocus && !isNonContiguousSelectionModifier(e)) {
manager.replaceSelection(key);
}
e.preventDefault();
}
};

Expand All @@ -178,7 +187,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
nextKey = delegate.getFirstKey?.(manager.focusedKey);
}
if (nextKey != null) {
e.preventDefault();
navigateToKey(nextKey);
}
}
Expand All @@ -193,7 +201,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
nextKey = delegate.getLastKey?.(manager.focusedKey);
}
if (nextKey != null) {
e.preventDefault();
navigateToKey(nextKey);
}
}
Expand All @@ -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');
}
}
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
}
}
Expand All @@ -272,25 +284,34 @@ 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();
manager.clearSelection();
}
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.
Expand Down
47 changes: 47 additions & 0 deletions packages/react-aria-components/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
});
});
});
24 changes: 24 additions & 0 deletions packages/react-aria-components/test/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Tabs>
<TabList aria-label="Test">
<Tab id="a">A</Tab>
<Tab id="b" isDisabled>B</Tab>
<Tab id="c">C</Tab>
</TabList>
<TabPanel id="a">A</TabPanel>
<TabPanel id="b">B</TabPanel>
<TabPanel id="c">C</TabPanel>
</Tabs>
);
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(
<Tabs>
Expand Down
Loading