Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
90 changes: 83 additions & 7 deletions packages/dev/s2-docs/pages/react-aria/useKeyboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const description = 'Handles keyboard interactions with improved event pr
<PageDescription>{docs.exports.useKeyboard.description}</PageDescription>

```tsx render
"use client"
"use client";
import React from 'react';
import {useKeyboard} from 'react-aria/useKeyboard';

Expand All @@ -41,12 +41,11 @@ function Example() {
<input
{...keyboardProps}
id="example" />
<ul style={{
height: 100,
overflow: 'auto',
border: '1px solid gray',
width: 200
}}>
<ul
style={{
maxHeight: '200px',
overflow: 'auto'
}}>
{events.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</>
Expand All @@ -62,6 +61,83 @@ This provides better modularity by default, so that a parent component doesn't r
that a child already handled. If the child doesn't handle the event (e.g. it was for an unknown key),
it can call `event.continuePropagation()` to allow parents to handle the event.

### Shortcuts

`useKeyboard` also accepts a `shortcuts` prop, which maps shortcut strings to handler functions.
Shortcuts combine modifiers and keys with `+` (e.g. `"Mod+s"`, `"Shift+ArrowLeft"`). Modifier names
are case-insensitive and can appear in any order. **Mod** means Command on macOS and Control on
other platforms. (You can also use dynamic keys to create platform-specific shortcuts.
`[key + (isMac() ? '+Alt' : '+Control')]`)

When a key is pressed, the event is matched against the shortcuts map. If a handler is found, it is
called after any `onKeyDown` handler. Handlers may return:

* Nothing — the shortcut is handled. Propagation is stopped and the default action is prevented.
* `true` or `false` — shorthand for preventing the default action (`true`) or allowing the browser
default and propagation to continue (`false`).
* An object with `shouldContinuePropagation` and/or `shouldPreventDefault` for fine-grained control.

If no shortcut matches, the event is propagated to parent elements.

```tsx render
"use client";
import React from 'react';
import {useKeyboard} from 'react-aria/useKeyboard';

function Example() {
let [events, setEvents] = React.useState<string[]>([]);
let add = (message: string) => setEvents(events => [message, ...events]);

let {keyboardProps: parentProps} = useKeyboard({
onKeyDown: () => add('parent onKeyDown')
});

let {keyboardProps: childProps} = useKeyboard({
shortcuts: {
'Mod+s': () => add('child shortcut: Mod+s (prevents save dialog)'),
'ArrowLeft': () => add('child shortcut: ArrowLeft (prevents default, stops propagation)'),
'ArrowRight': () => {
add('child shortcut: ArrowRight (allows default, continues propagation)');
return false;
}
},
onKeyDown: () => add('child onKeyDown')
});

return (
<>
<p>
Focus the text field and press <kbd>Mod</kbd>+<kbd>S</kbd>, <kbd>←</kbd>,
or <kbd>→</kbd>. <kbd>←</kbd> prevents the cursor from moving. <kbd>→</kbd> moves the
cursor and propagates to the parent. Press any other key to see unmatched events propagate.
</p>
<div
{...parentProps}
style={{
border: '1px solid gray',
padding: 16
}}>
<label htmlFor="shortcuts-example" style={{display: 'block', marginBottom: 8}}>
Text field
</label>
<input
{...childProps}
id="shortcuts-example"
defaultValue="Move the cursor with arrow keys"
style={{width: '100%'}} />
</div>
<ul
style={{
maxHeight: '200px',
overflow: 'auto'
}}>
{events.map((e, i) => <li key={i}>{e}</li>)}
</ul>
</>
);
}
```

## API

<FunctionAPI function={docs.exports.useKeyboard} links={docs.links} />
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria/src/interactions/useKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export interface KeyboardProps extends KeyboardEvents {
isDisabled?: boolean;
/** Keyboard shortcuts to handle. */
shortcuts?: KeyboardShortcutBindings;
/** Whether to allow repeating keys. */
allowRepeats?: boolean;
/** Whether to allow composing keys. */
allowComposing?: boolean;
}

Expand Down Expand Up @@ -57,8 +59,8 @@ export function useKeyboard(props: KeyboardProps): KeyboardResult {
return;
}

shortcutHandler(e);
props.onKeyDown?.(e);
shortcutHandler(e);
});
onKeyUp = createEventHandler<ReactKeyboardEvent<any>>(e => {
// If keyboard event didn't originate from a child of the current target,
Expand All @@ -74,9 +76,9 @@ export function useKeyboard(props: KeyboardProps): KeyboardResult {
e.continuePropagation();
return;
}
props.onKeyUp?.(e);
// implement shortcut handler on keyup, what should the map be called? or should it be another syntax on shortcuts?
e.continuePropagation();
props.onKeyUp?.(e);
});
} else {
onKeyDown = createEventHandler(props.onKeyDown);
Expand Down
5 changes: 5 additions & 0 deletions packages/react-aria/src/menu/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {getEventTarget} from '../utils/shadowdom/DOMFunctions';
import {getItemCount} from 'react-stately/private/collections/getItemCount';
import {handleLinkClick, useLinkProps, useRouter} from '../utils/openLink';
import {isFocusVisible, setInteractionModality} from '../interactions/useFocusVisible';
import {KeyboardShortcutBindings} from '../interactions/createKeyboardShortcutHandler';
import {menuData} from './utils';
import {mergeProps} from '../utils/mergeProps';
import {MouseEvent, useRef} from 'react';
Expand Down Expand Up @@ -130,6 +131,9 @@ export interface AriaMenuItemProps

/** Override of the selection manager. By default, `state.selectionManager` is used. */
selectionManager?: SelectionManager;

/** Keyboard shortcuts to handle. */
shortcuts?: KeyboardShortcutBindings;
Comment thread
snowystinger marked this conversation as resolved.
Outdated
}

/**
Expand Down Expand Up @@ -315,6 +319,7 @@ export function useMenuItem<T>(

let {keyboardProps} = useKeyboard({
shortcuts: {
...props.shortcuts,
' ': e => {
interaction.current = {pointerType: 'keyboard', key: ' '};
(getEventTarget(e) as HTMLElement).click();
Expand Down
72 changes: 35 additions & 37 deletions packages/react-aria/src/menu/useSubmenuTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,50 +185,48 @@ export function useSubmenuTrigger<T>(
})
};

let {keyboardProps: submenuTriggerKeyboardProps} = useKeyboard({
shortcuts: {
ArrowRight: () => {
if (!isDisabled) {
if (direction === 'ltr') {
if (!state.isOpen) {
onSubmenuOpen('first');
}
let submenuTriggerShortcuts = {
ArrowRight: () => {
if (!isDisabled) {
if (direction === 'ltr') {
if (!state.isOpen) {
onSubmenuOpen('first');
}

if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
return;
} else if (state.isOpen) {
onSubmenuClose();
return;
} else {
return false;
if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
return;
} else if (state.isOpen) {
onSubmenuClose();
return;
} else {
return false;
}
return false;
},
ArrowLeft: () => {
if (!isDisabled) {
if (direction === 'rtl') {
if (!state.isOpen) {
onSubmenuOpen('first');
}
}
return false;
},
ArrowLeft: () => {
if (!isDisabled) {
if (direction === 'rtl') {
if (!state.isOpen) {
onSubmenuOpen('first');
}

if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
return;
} else if (state.isOpen) {
onSubmenuClose();
return;
} else {
return false;
if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
focusWithoutScrolling(submenuRef.current);
}
return;
} else if (state.isOpen) {
onSubmenuClose();
return;
} else {
return false;
}
return false;
}
return false;
}
});
};

let onPressStart = (e: PressEvent) => {
if (!isDisabled && (e.pointerType === 'virtual' || e.pointerType === 'keyboard')) {
Expand Down Expand Up @@ -288,7 +286,7 @@ export function useSubmenuTrigger<T>(

return {
submenuTriggerProps: {
...(submenuTriggerKeyboardProps as any), // TODO: fix this
shortcuts: submenuTriggerShortcuts,
id: submenuTriggerId,
'aria-controls': state.isOpen ? overlayId : undefined,
'aria-haspopup': !isDisabled ? type : undefined,
Expand Down