Skip to content

Commit 9e1b070

Browse files
authored
feat: Keyboard shortcut handler (#9929)
* prototype * Change over all useSelectableCollection to use the new shortcuts syntax * try making object more readable * share types * simplify and add tests, move to interactions. fix outstanding bug * fix react 16 tests * Starting converting everything to use shortcuts * fix all of the lint * fix: calendar tests * convert calendar and date picker * actiongroup and datesegment and lint * simplify code * fix lint * fix ts that I don't think i broke * add combobox * simplify some code * fix lint * Remove "Sel" expansion * change to void return * fix ts * update everything to the void signature * review comments * remove stale todo
1 parent c74c5dd commit 9e1b070

32 files changed

Lines changed: 1408 additions & 823 deletions

packages/@adobe/react-spectrum/src/actionbar/ActionBar.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,8 @@ function ActionBarInner<T>(props: ActionBarInnerProps<T>, ref: Ref<HTMLDivElemen
110110
}
111111

112112
let {keyboardProps} = useKeyboard({
113-
onKeyDown(e) {
114-
if (e.key === 'Escape') {
115-
e.preventDefault();
113+
shortcuts: {
114+
Escape: () => {
116115
onClearSelection();
117116
}
118117
}

packages/@react-spectrum/s2/src/ActionBar.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,9 @@ const ActionBarInner = forwardRef(function ActionBarInner(
167167
});
168168

169169
let {keyboardProps} = useKeyboard({
170-
onKeyDown(e) {
171-
if (e.key === 'Escape') {
172-
e.preventDefault();
170+
shortcuts: {
171+
Escape: () => {
173172
onClearSelection?.();
174-
} else {
175-
e.continuePropagation();
176173
}
177174
}
178175
});

packages/react-aria-components/test/ListBox.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2396,3 +2396,40 @@ describe('ListBox', () => {
23962396
});
23972397
}
23982398
});
2399+
2400+
describe('keyboard modifier keys', () => {
2401+
let user;
2402+
let platformMock;
2403+
beforeAll(() => {
2404+
user = userEvent.setup({delay: null, pointerMap});
2405+
});
2406+
// selectionMode: 'none', 'single', 'multiple'
2407+
// selectionBehavior: 'toggle', 'replace'
2408+
// platform: 'mac', 'windows'
2409+
2410+
// modifier key: 'alt', 'ctrl', 'meta', 'shift'
2411+
// key: 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'home', 'end', 'page-up', 'page-down', 'enter', 'space', 'tab'
2412+
// expected behavior: 'navigate', 'select', 'toggle', 'replace'
2413+
describe('mac', () => {
2414+
beforeAll(() => {
2415+
platformMock = jest.spyOn(navigator, 'platform', 'get').mockImplementation(() => 'Mac');
2416+
});
2417+
afterAll(() => {
2418+
platformMock.mockRestore();
2419+
});
2420+
it('should not navigate when using unsupported modifier keys', async () => {
2421+
let {getByRole} = renderListbox({selectionMode: 'none'});
2422+
await user.tab();
2423+
let listbox = getByRole('listbox');
2424+
let options = within(listbox).getAllByRole('option');
2425+
await user.keyboard('{ArrowDown}');
2426+
expect(document.activeElement).toBe(options[1]);
2427+
await user.keyboard('{Meta>}{ArrowDown}{/Meta}');
2428+
expect(document.activeElement).toBe(options[1]);
2429+
await user.keyboard('{Meta>}{ArrowUp}{/Meta}');
2430+
expect(document.activeElement).toBe(options[1]);
2431+
await user.keyboard('{Control>}{Home}{/Control}');
2432+
expect(document.activeElement).toBe(options[1]);
2433+
});
2434+
});
2435+
});

packages/react-aria/src/actiongroup/useActionGroup.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import {
2424
} from '@react-types/shared';
2525
import {createFocusManager} from '../focus/FocusScope';
2626
import {filterDOMProps} from '../utils/filterDOMProps';
27-
import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
28-
import {KeyboardEventHandler, useState} from 'react';
2927
import {ListState} from 'react-stately/useListState';
28+
import {useKeyboard} from '../interactions/useKeyboard';
3029
import {useLayoutEffect} from '../utils/useLayoutEffect';
3130
import {useLocale} from '../i18n/I18nProvider';
31+
import {useState} from 'react';
3232

3333
const BUTTON_GROUP_ROLES = {
3434
none: 'toolbar',
@@ -91,34 +91,30 @@ export function useActionGroup<T>(
9191
let {direction} = useLocale();
9292
let focusManager = createFocusManager(ref);
9393
let flipDirection = direction === 'rtl' && orientation === 'horizontal';
94-
let onKeyDown: KeyboardEventHandler = e => {
95-
if (!nodeContains(e.currentTarget, getEventTarget(e))) {
96-
return;
97-
}
98-
99-
switch (e.key) {
100-
case 'ArrowRight':
101-
case 'ArrowDown':
102-
e.preventDefault();
103-
e.stopPropagation();
104-
if (e.key === 'ArrowRight' && flipDirection) {
94+
let {keyboardProps} = useKeyboard({
95+
shortcuts: {
96+
ArrowRight: () => {
97+
if (flipDirection) {
10598
focusManager.focusPrevious({wrap: true});
10699
} else {
107100
focusManager.focusNext({wrap: true});
108101
}
109-
break;
110-
case 'ArrowLeft':
111-
case 'ArrowUp':
112-
e.preventDefault();
113-
e.stopPropagation();
114-
if (e.key === 'ArrowLeft' && flipDirection) {
102+
},
103+
ArrowDown: () => {
104+
focusManager.focusNext({wrap: true});
105+
},
106+
ArrowLeft: () => {
107+
if (flipDirection) {
115108
focusManager.focusNext({wrap: true});
116109
} else {
117110
focusManager.focusPrevious({wrap: true});
118111
}
119-
break;
112+
},
113+
ArrowUp: () => {
114+
focusManager.focusPrevious({wrap: true});
115+
}
120116
}
121-
};
117+
});
122118

123119
let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode];
124120
if (isInToolbar && role === 'toolbar') {
@@ -130,7 +126,7 @@ export function useActionGroup<T>(
130126
role,
131127
'aria-orientation': role === 'toolbar' ? orientation : undefined,
132128
'aria-disabled': isDisabled,
133-
onKeyDown
129+
...keyboardProps
134130
}
135131
};
136132
}

packages/react-aria/src/calendar/useCalendarGrid.ts

Lines changed: 38 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import {CalendarDate, startOfWeek, today} from '@internationalized/date';
1414
import {CalendarSelectionMode, CalendarState} from 'react-stately/useCalendarState';
1515
import {DOMAttributes} from '@react-types/shared';
1616
import {hookData, useVisibleRangeDescription} from './utils';
17-
import {KeyboardEvent, useMemo} from 'react';
1817
import {mergeProps} from '../utils/mergeProps';
1918
import {RangeCalendarState} from 'react-stately/useRangeCalendarState';
2019
import {useDateFormatter} from '../i18n/useDateFormatter';
20+
import {useKeyboard} from '../interactions/useKeyboard';
2121
import {useLabels} from '../utils/useLabels';
2222
import {useLocale} from '../i18n/I18nProvider';
23+
import {useMemo} from 'react';
2324

2425
export interface AriaCalendarGridProps {
2526
/**
@@ -78,70 +79,61 @@ export function useCalendarGrid(
7879

7980
let {direction} = useLocale();
8081

81-
let onKeyDown = (e: KeyboardEvent) => {
82-
switch (e.key) {
83-
case 'Enter':
84-
case ' ':
85-
e.preventDefault();
82+
let {keyboardProps} = useKeyboard({
83+
shortcuts: {
84+
Enter: () => {
8685
state.selectFocusedDate();
87-
break;
88-
case 'PageUp':
89-
e.preventDefault();
90-
e.stopPropagation();
91-
state.focusPreviousSection(e.shiftKey);
92-
break;
93-
case 'PageDown':
94-
e.preventDefault();
95-
e.stopPropagation();
96-
state.focusNextSection(e.shiftKey);
97-
break;
98-
case 'End':
99-
e.preventDefault();
100-
e.stopPropagation();
86+
},
87+
' ': () => {
88+
state.selectFocusedDate();
89+
},
90+
PageUp: () => {
91+
state.focusPreviousSection();
92+
},
93+
'Shift+PageUp': () => {
94+
state.focusPreviousSection(true);
95+
},
96+
PageDown: () => {
97+
state.focusNextSection();
98+
},
99+
'Shift+PageDown': () => {
100+
state.focusNextSection(true);
101+
},
102+
End: () => {
101103
state.focusSectionEnd();
102-
break;
103-
case 'Home':
104-
e.preventDefault();
105-
e.stopPropagation();
104+
},
105+
Home: () => {
106106
state.focusSectionStart();
107-
break;
108-
case 'ArrowLeft':
109-
e.preventDefault();
110-
e.stopPropagation();
107+
},
108+
ArrowLeft: () => {
111109
if (direction === 'rtl') {
112110
state.focusNextDay();
113111
} else {
114112
state.focusPreviousDay();
115113
}
116-
break;
117-
case 'ArrowUp':
118-
e.preventDefault();
119-
e.stopPropagation();
114+
},
115+
ArrowUp: () => {
120116
state.focusPreviousRow();
121-
break;
122-
case 'ArrowRight':
123-
e.preventDefault();
124-
e.stopPropagation();
117+
},
118+
ArrowRight: () => {
125119
if (direction === 'rtl') {
126120
state.focusPreviousDay();
127121
} else {
128122
state.focusNextDay();
129123
}
130-
break;
131-
case 'ArrowDown':
132-
e.preventDefault();
133-
e.stopPropagation();
124+
},
125+
ArrowDown: () => {
134126
state.focusNextRow();
135-
break;
136-
case 'Escape':
127+
},
128+
Escape: () => {
137129
// Cancel the selection.
138130
if ('setAnchorDate' in state) {
139-
e.preventDefault();
140131
state.setAnchorDate(null);
141132
}
142-
break;
133+
return false; // TODO: is this really correct? or should it return true when we cancel and only propagate if there's nothing to do
134+
}
143135
}
144-
};
136+
});
145137

146138
let visibleRangeDescription = useVisibleRangeDescription(
147139
startDate,
@@ -182,7 +174,7 @@ export function useCalendarGrid(
182174
'aria-disabled': state.isDisabled || undefined,
183175
'aria-multiselectable':
184176
'highlightedRange' in state || state.selectionMode === 'multiple' || undefined,
185-
onKeyDown,
177+
...keyboardProps,
186178
onFocus: () => state.setFocused(true),
187179
onBlur: () => state.setFocused(false)
188180
}),

packages/react-aria/src/color/useColorArea.ts

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -111,46 +111,56 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState)
111111

112112
let currentPosition = useRef<{x: number; y: number} | null>(null);
113113

114+
let keyboardUpdate = (cb, inputRef: RefObject<HTMLInputElement | null>, input: 'x' | 'y') => {
115+
state.setDragging(true);
116+
setValueChangedViaKeyboard(true);
117+
cb();
118+
state.setDragging(false);
119+
focusInput(inputRef);
120+
setFocusedInput(input);
121+
};
122+
114123
let {keyboardProps} = useKeyboard({
115-
onKeyDown(e) {
116-
// these are the cases that useMove doesn't handle
117-
if (!/^(PageUp|PageDown|Home|End)$/.test(e.key)) {
118-
e.continuePropagation();
119-
return;
120-
}
121-
// same handling as useMove, don't need to stop propagation, useKeyboard will do that for us
122-
e.preventDefault();
123-
// remember to set this and unset it so that onChangeEnd is fired
124-
state.setDragging(true);
125-
setValueChangedViaKeyboard(true);
126-
let dir;
127-
switch (e.key) {
128-
case 'PageUp':
129-
state.incrementY(state.yChannelPageStep);
130-
dir = 'y';
131-
break;
132-
case 'PageDown':
133-
state.decrementY(state.yChannelPageStep);
134-
dir = 'y';
135-
break;
136-
case 'Home':
137-
direction === 'rtl'
138-
? state.incrementX(state.xChannelPageStep)
139-
: state.decrementX(state.xChannelPageStep);
140-
dir = 'x';
141-
break;
142-
case 'End':
143-
direction === 'rtl'
144-
? state.decrementX(state.xChannelPageStep)
145-
: state.incrementX(state.xChannelPageStep);
146-
dir = 'x';
147-
break;
148-
}
149-
state.setDragging(false);
150-
if (dir) {
151-
let input = dir === 'x' ? inputXRef : inputYRef;
152-
focusInput(input);
153-
setFocusedInput(dir);
124+
shortcuts: {
125+
PageUp: () => {
126+
return keyboardUpdate(
127+
() => {
128+
state.incrementY(state.yChannelPageStep);
129+
},
130+
inputYRef,
131+
'y'
132+
);
133+
},
134+
PageDown: () => {
135+
return keyboardUpdate(
136+
() => {
137+
state.decrementY(state.yChannelPageStep);
138+
},
139+
inputYRef,
140+
'y'
141+
);
142+
},
143+
Home: () => {
144+
return keyboardUpdate(
145+
() => {
146+
direction === 'rtl'
147+
? state.incrementX(state.xChannelPageStep)
148+
: state.decrementX(state.xChannelPageStep);
149+
},
150+
inputXRef,
151+
'x'
152+
);
153+
},
154+
End: () => {
155+
return keyboardUpdate(
156+
() => {
157+
direction === 'rtl'
158+
? state.decrementX(state.xChannelPageStep)
159+
: state.incrementX(state.xChannelPageStep);
160+
},
161+
inputXRef,
162+
'x'
163+
);
154164
}
155165
}
156166
});

0 commit comments

Comments
 (0)