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
40 changes: 39 additions & 1 deletion 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,6 +137,8 @@ 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) => {
if (key != null) {
if (manager.isLink(key) && linkBehavior === 'selection' && selectOnFocus && !isNonContiguousSelectionModifier(e)) {
Expand Down Expand Up @@ -168,8 +170,14 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
};

let shouldIgnoreModifierKeys = e.metaKey || (e.shiftKey && manager.selectionMode !== 'multiple') || (!isAppleDevice() ? e.altKey : e.ctrlKey);

switch (e.key) {
case 'ArrowDown': {
// inverse of navigateToKey's usage
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyBelow) {
let nextKey = manager.focusedKey != null
? delegate.getKeyBelow?.(manager.focusedKey)
Expand All @@ -185,6 +193,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
break;
}
case 'ArrowUp': {
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyAbove) {
let nextKey = manager.focusedKey != null
? delegate.getKeyAbove?.(manager.focusedKey)
Expand All @@ -200,6 +211,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
break;
}
case 'ArrowLeft': {
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyLeftOf) {
let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyLeftOf?.(manager.focusedKey) : delegate.getFirstKey?.();
if (nextKey == null && shouldFocusWrap) {
Expand All @@ -213,6 +227,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
break;
}
case 'ArrowRight': {
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyRightOf) {
let nextKey: Key | undefined | null = manager.focusedKey != null ? delegate.getKeyRightOf?.(manager.focusedKey) : delegate.getFirstKey?.();
if (nextKey == null && shouldFocusWrap) {
Expand All @@ -226,6 +243,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
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;
Expand All @@ -243,6 +263,9 @@ 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;
Expand All @@ -260,6 +283,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
break;
case 'PageDown':
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyPageBelow && manager.focusedKey != null) {
let nextKey = delegate.getKeyPageBelow(manager.focusedKey);
if (nextKey != null) {
Expand All @@ -269,6 +295,9 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
break;
case 'PageUp':
if (shouldIgnoreModifierKeys) {
return;
}
if (delegate.getKeyPageAbove && manager.focusedKey != null) {
let nextKey = delegate.getKeyPageAbove(manager.focusedKey);
if (nextKey != null) {
Expand All @@ -278,19 +307,28 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
}
break;
case 'a':
if (e.altKey || e.shiftKey || (isAppleDevice() ? e.ctrlKey : e.metaKey)) {
return;
}
if (isCtrlKeyPressed(e) && manager.selectionMode === 'multiple' && disallowSelectAll !== true) {
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