Skip to content

Commit a0e8ea0

Browse files
authored
fix: Fix onAction with keyboard in ComboBox and add docs (#8910)
* fix: Fix onAction with keyboard in ComboBox and add docs * Fix old React
1 parent 307b71c commit a0e8ea0

File tree

7 files changed

+198
-12
lines changed

7 files changed

+198
-12
lines changed

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,19 +139,22 @@ export function useComboBox<T>(props: AriaComboBoxOptions<T>, state: ComboBoxSta
139139
}
140140

141141
// If the focused item is a link, trigger opening it. Items that are links are not selectable.
142-
if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null && state.selectionManager.isLink(state.selectionManager.focusedKey)) {
143-
let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`);
144-
if (e.key === 'Enter' && item instanceof HTMLAnchorElement) {
145-
let collectionItem = state.collection.getItem(state.selectionManager.focusedKey);
146-
if (collectionItem) {
142+
if (state.isOpen && listBoxRef.current && state.selectionManager.focusedKey != null) {
143+
let collectionItem = state.collection.getItem(state.selectionManager.focusedKey);
144+
if (collectionItem?.props.href) {
145+
let item = listBoxRef.current.querySelector(`[data-key="${CSS.escape(state.selectionManager.focusedKey.toString())}"]`);
146+
if (e.key === 'Enter' && item instanceof HTMLAnchorElement) {
147147
router.open(item, e, collectionItem.props.href, collectionItem.props.routerOptions as RouterOptions);
148148
}
149+
state.close();
150+
break;
151+
} else if (collectionItem?.props.onAction) {
152+
collectionItem.props.onAction();
153+
state.close();
154+
break;
149155
}
150-
151-
state.close();
152-
} else {
153-
state.commit();
154156
}
157+
state.commit();
155158
break;
156159
case 'Escape':
157160
if (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ export function ComboBoxItem(props: ComboBoxItemProps): ReactNode {
399399
}
400400
}]
401401
]}>
402-
{!isLink && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
402+
{!isLink && !props.onAction && <CheckmarkIcon size={checkmarkIconSize[size]} className={checkmark({...renderProps, size})} />}
403403
{typeof children === 'string' ? <Text slot="label">{children}</Text> : children}
404404
</Provider>
405405
</>

packages/@react-spectrum/s2/stories/ComboBox.stories.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {ComboBoxProps} from 'react-aria-components';
1616
import DeviceDesktopIcon from '../s2wf-icons/S2_Icon_DeviceDesktop_20_N.svg';
1717
import DeviceTabletIcon from '../s2wf-icons/S2_Icon_DeviceTablet_20_N.svg';
1818
import type {Meta, StoryObj} from '@storybook/react';
19-
import {ReactElement} from 'react';
19+
import {ReactElement, useState} from 'react';
2020
import {style} from '../style' with {type: 'macro'};
2121
import {useAsyncList} from 'react-stately';
2222

@@ -307,3 +307,26 @@ export const EmptyCombobox: Story = {
307307
}
308308
}
309309
};
310+
311+
export function WithCreateOption() {
312+
let [inputValue, setInputValue] = useState('');
313+
314+
return (
315+
<ComboBox
316+
label="Favorite Animal"
317+
inputValue={inputValue}
318+
onInputChange={setInputValue}>
319+
{inputValue.length > 0 && (
320+
<ComboBoxItem onAction={() => alert('hi')}>
321+
{`Create "${inputValue}"`}
322+
</ComboBoxItem>
323+
)}
324+
<ComboBoxItem>Aardvark</ComboBoxItem>
325+
<ComboBoxItem>Cat</ComboBoxItem>
326+
<ComboBoxItem>Dog</ComboBoxItem>
327+
<ComboBoxItem>Kangaroo</ComboBoxItem>
328+
<ComboBoxItem>Panda</ComboBoxItem>
329+
<ComboBoxItem>Snake</ComboBoxItem>
330+
</ComboBox>
331+
);
332+
}

packages/dev/s2-docs/pages/react-aria/ComboBox.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,42 @@ function ControlledComboBox() {
232232
}
233233
```
234234

235+
## Item actions
236+
237+
Use the `onAction` prop on a `<ListBoxItem>` to perform a custom action when the item is selected. This example adds a "Create" action for the current input value.
238+
239+
```tsx render
240+
"use client";
241+
import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox';
242+
import {useState} from 'react';
243+
244+
function Example() {
245+
let [inputValue, setInputValue] = useState('');
246+
247+
return (
248+
<ComboBox
249+
label="Favorite Animal"
250+
allowsEmptyCollection
251+
inputValue={inputValue}
252+
onInputChange={setInputValue}>
253+
{/*- begin highlight -*/}
254+
{inputValue.length > 0 && (
255+
<ComboBoxItem onAction={() => alert('Creating ' + inputValue)}>
256+
{`Create "${inputValue}"`}
257+
</ComboBoxItem>
258+
)}
259+
{/*- end highlight -*/}
260+
<ComboBoxItem>Aardvark</ComboBoxItem>
261+
<ComboBoxItem>Cat</ComboBoxItem>
262+
<ComboBoxItem>Dog</ComboBoxItem>
263+
<ComboBoxItem>Kangaroo</ComboBoxItem>
264+
<ComboBoxItem>Panda</ComboBoxItem>
265+
<ComboBoxItem>Snake</ComboBoxItem>
266+
</ComboBox>
267+
);
268+
}
269+
```
270+
235271
## Forms
236272

237273
Use the `name` prop to submit the `id` of the selected item to the server. Set the `isRequired` prop to validate that the user selects a value, or implement custom client or server-side validation. See the Forms guide to learn more.

packages/dev/s2-docs/pages/s2/ComboBox.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,41 @@ function Example() {
164164
}
165165
```
166166

167+
### Actions
168+
169+
Use the `onAction` prop on a `<ComboBoxItem>` to perform a custom action when the item is selected. This example adds a "Create" action for the current input value.
170+
171+
```tsx render
172+
"use client";
173+
import {ComboBox, ComboBoxItem} from '@react-spectrum/s2';
174+
import {useState} from 'react';
175+
176+
function Example() {
177+
let [inputValue, setInputValue] = useState('');
178+
179+
return (
180+
<ComboBox
181+
label="Favorite Animal"
182+
inputValue={inputValue}
183+
onInputChange={setInputValue}>
184+
{/*- begin highlight -*/}
185+
{inputValue.length > 0 && (
186+
<ComboBoxItem onAction={() => alert('Creating ' + inputValue)}>
187+
{`Create "${inputValue}"`}
188+
</ComboBoxItem>
189+
)}
190+
{/*- end highlight -*/}
191+
<ComboBoxItem>Aardvark</ComboBoxItem>
192+
<ComboBoxItem>Cat</ComboBoxItem>
193+
<ComboBoxItem>Dog</ComboBoxItem>
194+
<ComboBoxItem>Kangaroo</ComboBoxItem>
195+
<ComboBoxItem>Panda</ComboBoxItem>
196+
<ComboBoxItem>Snake</ComboBoxItem>
197+
</ComboBox>
198+
);
199+
}
200+
```
201+
167202
### Links
168203

169204
Use the `href` prop on a `<ComboBoxItem>` to create a link. See the **client side routing guide** to learn how to integrate with your framework. Link items in a `ComboBox` are not selectable.

packages/react-aria-components/stories/ComboBox.stories.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,39 @@ const MyListBoxLoaderIndicator = (props) => {
331331
</ListBoxLoadMoreItem>
332332
);
333333
};
334+
335+
export function WithCreateOption() {
336+
let [inputValue, setInputValue] = useState('');
337+
338+
return (
339+
<ComboBox
340+
allowsEmptyCollection
341+
inputValue={inputValue}
342+
onInputChange={setInputValue}>
343+
<Label style={{display: 'block'}}>Favorite Animal</Label>
344+
<div style={{display: 'flex'}}>
345+
<Input />
346+
<Button>
347+
<span aria-hidden="true" style={{padding: '0 2px'}}></span>
348+
</Button>
349+
</div>
350+
<Popover placement="bottom end">
351+
<ListBox
352+
data-testid="combo-box-list-box"
353+
className={styles.menu}>
354+
{inputValue.length > 0 && (
355+
<MyListBoxItem onAction={() => alert('hi')}>
356+
{`Create "${inputValue}"`}
357+
</MyListBoxItem>
358+
)}
359+
<MyListBoxItem>Aardvark</MyListBoxItem>
360+
<MyListBoxItem>Cat</MyListBoxItem>
361+
<MyListBoxItem>Dog</MyListBoxItem>
362+
<MyListBoxItem>Kangaroo</MyListBoxItem>
363+
<MyListBoxItem>Panda</MyListBoxItem>
364+
<MyListBoxItem>Snake</MyListBoxItem>
365+
</ListBox>
366+
</Popover>
367+
</ComboBox>
368+
);
369+
}

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

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {act} from '@testing-library/react';
1414
import {Button, ComboBox, ComboBoxContext, FieldError, Header, Input, Label, ListBox, ListBoxItem, ListBoxLoadMoreItem, ListBoxSection, ListLayout, Popover, Text, Virtualizer} from '../';
1515
import {fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
16-
import React from 'react';
16+
import React, {useState} from 'react';
1717
import {User} from '@react-aria/test-utils';
1818
import userEvent from '@testing-library/user-event';
1919

@@ -408,4 +408,57 @@ describe('ComboBox', () => {
408408
expect(comboboxTester.listbox).toBeTruthy();
409409
expect(options[0]).toHaveTextContent('No results');
410410
});
411+
412+
it('should support onAction', async () => {
413+
let onAction = jest.fn();
414+
function WithCreateOption() {
415+
let [inputValue, setInputValue] = useState('');
416+
417+
return (
418+
<ComboBox
419+
allowsEmptyCollection
420+
inputValue={inputValue}
421+
onInputChange={setInputValue}>
422+
<Label style={{display: 'block'}}>Favorite Animal</Label>
423+
<div style={{display: 'flex'}}>
424+
<Input />
425+
<Button>
426+
<span aria-hidden="true" style={{padding: '0 2px'}}></span>
427+
</Button>
428+
</div>
429+
<Popover placement="bottom end">
430+
<ListBox>
431+
{inputValue.length > 0 && (
432+
<ListBoxItem onAction={onAction}>
433+
{`Create "${inputValue}"`}
434+
</ListBoxItem>
435+
)}
436+
<ListBoxItem>Aardvark</ListBoxItem>
437+
<ListBoxItem>Cat</ListBoxItem>
438+
<ListBoxItem>Dog</ListBoxItem>
439+
<ListBoxItem>Kangaroo</ListBoxItem>
440+
<ListBoxItem>Panda</ListBoxItem>
441+
<ListBoxItem>Snake</ListBoxItem>
442+
</ListBox>
443+
</Popover>
444+
</ComboBox>
445+
);
446+
}
447+
448+
let tree = render(<WithCreateOption />);
449+
let comboboxTester = testUtilUser.createTester('ComboBox', {root: tree.container});
450+
act(() => {
451+
comboboxTester.combobox.focus();
452+
});
453+
454+
await user.keyboard('L');
455+
456+
let options = comboboxTester.options();
457+
expect(options).toHaveLength(1);
458+
expect(options[0]).toHaveTextContent('Create "L"');
459+
460+
await user.keyboard('{ArrowDown}{Enter}');
461+
expect(onAction).toHaveBeenCalledTimes(1);
462+
expect(comboboxTester.combobox).toHaveValue('');
463+
});
411464
});

0 commit comments

Comments
 (0)