Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d2b7a2c
Add `onKeyDown` prop to ListBoxItem for custom keyboard handling
hasegawa-101 Nov 13, 2025
b404954
Update type annotation in ListBox example and fix style formatting in…
hasegawa-101 Nov 13, 2025
dfd80e1
fix: remove custom keyboard handling example from ListBox docs and story
hasegawa-101 Nov 14, 2025
49e754d
test: add unit tests for `onKeyDown` prop in ListBox component
hasegawa-101 Nov 14, 2025
330d9a3
Merge branch 'main' into issue-8732
hasegawa-101 Nov 14, 2025
00f7be5
test: remove redundant assertions for `onKeyDown` in ListBox tests
hasegawa-101 Nov 14, 2025
e7356f7
test: update `onKeyDown` tests in ListBox to use user-event for bette…
hasegawa-101 Nov 15, 2025
2fbcf50
Add `onKeyDown` prop to ListBoxItem for custom keyboard handling
hasegawa-101 Nov 13, 2025
139f601
Update type annotation in ListBox example and fix style formatting in…
hasegawa-101 Nov 13, 2025
f1d2493
fix: remove custom keyboard handling example from ListBox docs and story
hasegawa-101 Nov 14, 2025
299d78c
test: add unit tests for `onKeyDown` prop in ListBox component
hasegawa-101 Nov 14, 2025
0c8979b
test: remove redundant assertions for `onKeyDown` in ListBox tests
hasegawa-101 Nov 14, 2025
81ccc92
test: update `onKeyDown` tests in ListBox to use user-event for bette…
hasegawa-101 Nov 15, 2025
416ceee
Merge remote-tracking branch 'origin/issue-8732' into issue-8732
hasegawa-101 Nov 15, 2025
cded5a0
chore: remove unnecessary blank lines in ListBox story
hasegawa-101 Nov 15, 2025
175fc31
chore: remove trailing blank line in ListBox story
hasegawa-101 Nov 15, 2025
e9bd591
Merge branch 'main' into issue-8732
snowystinger Dec 18, 2025
d54a2d2
Add tests and move to useKeyboard
snowystinger Dec 18, 2025
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
9 changes: 5 additions & 4 deletions packages/react-aria-components/src/ListBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocus, useFocusRing, useHover, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria';
import {AriaListBoxOptions, AriaListBoxProps, DraggableItemResult, DragPreviewRenderer, DroppableCollectionResult, DroppableItemResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocus, useFocusRing, useHover, useKeyboard, useListBox, useListBoxSection, useLocale, useOption} from 'react-aria';
import {
ClassNameOrFunction,
ContextValue,
Expand All @@ -30,7 +30,7 @@ import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPers
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableCollectionState, DroppableCollectionState, ListState, Node, Orientation, SelectionBehavior, UNSTABLE_useFilteredListState, useListState} from 'react-stately';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {FocusEvents, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {FocusEvents, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, KeyboardEvents, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {HeaderContext} from './Header';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {SelectableCollectionContext, SelectableCollectionContextValue} from './RSPContexts';
Expand Down Expand Up @@ -336,7 +336,7 @@ export const ListBoxSection = /*#__PURE__*/ createBranchComponent(SectionNode, L

export interface ListBoxItemRenderProps extends ItemRenderProps {}

export interface ListBoxItemProps<T = object> extends RenderProps<ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, FocusEvents<HTMLDivElement>, Omit<GlobalDOMAttributes<HTMLDivElement>, 'onClick'> {
export interface ListBoxItemProps<T = object> extends RenderProps<ListBoxItemRenderProps>, LinkDOMProps, HoverEvents, PressEvents, KeyboardEvents, FocusEvents<HTMLDivElement>, Omit<GlobalDOMAttributes<HTMLDivElement>, 'onClick'> {
/**
* The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. A function may be provided to compute the class based on component state.
* @default 'react-aria-ListBoxItem'
Expand Down Expand Up @@ -379,6 +379,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function
onHoverEnd: item.props.onHoverEnd
});

let {keyboardProps} = useKeyboard(props);
let {focusProps} = useFocus(props);

let draggableItem: DraggableItemResult | null = null;
Expand Down Expand Up @@ -428,7 +429,7 @@ export const ListBoxItem = /*#__PURE__*/ createLeafComponent(ItemNode, function

return (
<ElementType
{...mergeProps(DOMProps, renderProps, optionProps, hoverProps, focusProps, draggableItem?.dragProps, droppableItem?.dropProps)}
{...mergeProps(DOMProps, renderProps, optionProps, hoverProps, keyboardProps, focusProps, draggableItem?.dragProps, droppableItem?.dropProps)}
ref={ref}
data-allows-dragging={!!dragState || undefined}
data-selected={states.isSelected || undefined}
Expand Down
24 changes: 23 additions & 1 deletion packages/react-aria-components/test/ListBox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1319,7 +1319,7 @@ describe('ListBox', () => {
act(() => jest.runAllTimers());

expect(onReorder).toHaveBeenCalledTimes(1);

// Verify we're no longer in drag mode
options = getAllByRole('option');
expect(options.filter(opt => opt.classList.contains('react-aria-DropIndicator'))).toHaveLength(0);
Expand Down Expand Up @@ -1861,4 +1861,26 @@ describe('ListBox', () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
});

describe('onKeyDown', () => {
it('should call key handler when key is pressed on item', async () => {
let onKeyDown = jest.fn((e) => e.continuePropagation());
Copy link
Member

Choose a reason for hiding this comment

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

question to team, do we want to use useKeyboard for this, it stops events by default so we MUST call continuePropagation here for the "Escape" key to clear the selection.

This is not ideal because it causes the event to continue by default instead through all other event handlers. See my thoughts on this in the description of #8775

Copy link
Contributor

Choose a reason for hiding this comment

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

This is not ideal because it causes the event to continue by default instead through all other event handlers.

Hm, maybe I’m remembering something wrong but isn't that only an issue because the handlers further up do not consistently useKeyboard themselves?

If that were the case propagation wouldnt flow upwards uncontrollably, since it would be stopped at every level if not explicitly continued.

Copy link
Member

Choose a reason for hiding this comment

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

That's a good point, I might've been thinking about it wrong. Looking at https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/interactions/src/createEventHandler.ts which would be called each time useKeyboard is used it looks like the previous continue would be overwritten and stop would be called.

I still do not like that users would need to know all the keys they should continue for the ListBox to work though. So I'm still not sure about using useKeyboard here.

Copy link
Contributor

@nwidynski nwidynski Dec 18, 2025

Choose a reason for hiding this comment

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

I'm not sure why a user wouldn't just call continue for any key, besides the ones with explicit purpose in his custom event handler. The same should be done in our parent handler, aka continue on everything we don't explicitly want to stop for. It basically translates to the exact thing you proposed in #8775, where there is an explicit intent of I do not want collisions to this key attached to any stop and yet we do not continue uncontrollably because we still need to think about calling continue explicitly every time we add a new handler.

I guess you could still argue that the user would have to know there is some keyboard interactivity that he may break if he doesn't continue in his handler, but I figure that would be discovered rather quickly.

let onKeyUp = jest.fn();
let onSelectionChange = jest.fn();
renderListbox({selectionMode: 'multiple', onSelectionChange}, {onKeyDown, onKeyUp});

await user.tab();
expect(onKeyUp).toHaveBeenCalledTimes(1);
onKeyUp.mockClear();
await user.keyboard('{Enter}');
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onKeyUp).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledTimes(1);

await user.keyboard('{Escape}');
expect(onKeyDown).toHaveBeenCalledTimes(2);
expect(onKeyUp).toHaveBeenCalledTimes(2);
expect(onSelectionChange).toHaveBeenCalledTimes(2);
});
});
});