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
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably shouldn't add this as a prop

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, i was unsure the best way to handle this. The issue is that if shortcuts doesn't handle it, we automatically call continuePropagation on it. The issue though is that useMenuItem and useSubmenuTrigger are attaching keyboard handlers to the same element, through the same useKeyboard because we pass onKeyDown/onKeyUp straight through on the props.
I'll try something new on monday to see if i can undo this.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah fwiw daniel ran into the same thing in combobox I think, one of the reasons we reverted for this release. he had some ideas but ultimately it seemed too risky for now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i chatted with him about it, makes sense

}

/**
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