Skip to content

Commit 271277c

Browse files
kirilMatsiukKiril MatsiukyihuiliaosnowystingerLFDanLu
authored
Fix: Enable tabbing to tag remove buttons (#8002)
* Fix tag keyboard navigation to allow tabbing to remove buttons * fix lint * Add more integration level tests and stories, fix controls * fix case where tabbing moves to prev/next clear button instead of moving out of collection * get rid of extraneous tree walker --------- Co-authored-by: Kiril Matsiuk <[email protected]> Co-authored-by: Yihui Liao <[email protected]> Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Daniel Lu <[email protected]>
1 parent e2f1c3b commit 271277c

File tree

8 files changed

+330
-77
lines changed

8 files changed

+330
-77
lines changed

packages/@react-aria/gridlist/src/useGridListItem.ts

+25-14
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
131131
linkBehavior
132132
});
133133

134-
let onKeyDown = (e: ReactKeyboardEvent) => {
134+
let onKeyDownCapture = (e: ReactKeyboardEvent) => {
135135
if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) {
136136
return;
137137
}
@@ -225,18 +225,6 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
225225
);
226226
}
227227
break;
228-
case 'Tab': {
229-
if (keyboardNavigationBehavior === 'tab') {
230-
// If there is another focusable element within this item, stop propagation so the tab key
231-
// is handled by the browser and not by useSelectableCollection (which would take us out of the list).
232-
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
233-
walker.currentNode = document.activeElement;
234-
let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
235-
if (next) {
236-
e.stopPropagation();
237-
}
238-
}
239-
}
240228
}
241229
};
242230

@@ -256,6 +244,28 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
256244
}
257245
};
258246

247+
let onKeyDown = (e) => {
248+
if (!e.currentTarget.contains(e.target as Element) || !ref.current || !document.activeElement) {
249+
return;
250+
}
251+
252+
switch (e.key) {
253+
case 'Tab': {
254+
if (keyboardNavigationBehavior === 'tab') {
255+
// If there is another focusable element within this item, stop propagation so the tab key
256+
// is handled by the browser and not by useSelectableCollection (which would take us out of the list).
257+
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
258+
walker.currentNode = document.activeElement;
259+
let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
260+
261+
if (next) {
262+
e.stopPropagation();
263+
}
264+
}
265+
}
266+
}
267+
};
268+
259269
let syntheticLinkProps = useSyntheticLinkProps(node.props);
260270
let linkProps = itemStates.hasAction ? syntheticLinkProps : {};
261271
// TODO: re-add when we get translations and fix this for iOS VO
@@ -270,7 +280,8 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
270280

271281
let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
272282
role: 'row',
273-
onKeyDownCapture: onKeyDown,
283+
onKeyDownCapture,
284+
onKeyDown,
274285
onFocus,
275286
// 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '),
276287
'aria-label': node.textValue || undefined,

packages/@react-aria/tag/src/useTag.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
5656
node: item
5757
}, state, ref);
5858

59-
// We want the group to handle keyboard navigation between tags.
60-
delete rowProps.onKeyDownCapture;
6159
// eslint-disable-next-line @typescript-eslint/no-unused-vars
6260
let {descriptionProps: _, ...stateWithoutDescription} = states;
6361

@@ -103,8 +101,7 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
103101
'aria-labelledby': `${buttonId} ${rowProps.id}`,
104102
isDisabled,
105103
id: buttonId,
106-
onPress: () => onRemove ? onRemove(new Set([item.key])) : null,
107-
excludeFromTabOrder: true
104+
onPress: () => onRemove ? onRemove(new Set([item.key])) : null
108105
},
109106
rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, {
110107
tabIndex,

packages/@react-aria/tag/src/useTagGroup.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ export function useTagGroup<T>(props: AriaTagGroupOptions<T>, state: ListState<T
8080
...fieldProps,
8181
keyboardDelegate,
8282
shouldFocusWrap: true,
83-
linkBehavior: 'override'
83+
linkBehavior: 'override',
84+
keyboardNavigationBehavior: 'tab'
8485
}, state, ref);
8586

8687
let [isFocusWithin, setFocusWithin] = useState(false);

packages/@react-aria/tag/test/useTagGroup.test.js

+99
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,103 @@ describe('useTagGroup', function () {
112112
expect(onRemove).toHaveBeenCalledTimes(3);
113113
expect(onRemove).toHaveBeenLastCalledWith(new Set(['pool']));
114114
});
115+
116+
it('should support tabbing to tags and arrow navigation between tags', async () => {
117+
let {getAllByRole} = render(
118+
<TagGroup
119+
label="Amenities">
120+
<Item key="laundry">Laundry</Item>
121+
<Item key="fitness">Fitness center</Item>
122+
<Item key="parking">Parking</Item>
123+
</TagGroup>
124+
);
125+
126+
let tags = getAllByRole('row');
127+
expect(tags).toHaveLength(3);
128+
129+
// Initially, nothing should be focused
130+
expect(document.activeElement).not.toBe(tags[0]);
131+
132+
// Tab to focus the first tag
133+
await user.tab();
134+
expect(document.activeElement).toBe(tags[0]);
135+
136+
// Check that we can focus each tag with keyboard
137+
// Instead of using tab, let's verify we can move with arrow keys
138+
await user.keyboard('{ArrowRight}');
139+
expect(document.activeElement).toBe(tags[1]);
140+
141+
await user.keyboard('{ArrowRight}');
142+
expect(document.activeElement).toBe(tags[2]);
143+
144+
// Test we can go back
145+
await user.keyboard('{ArrowLeft}');
146+
expect(document.activeElement).toBe(tags[1]);
147+
});
148+
149+
it('should support tabbing to remove buttons', async () => {
150+
let onRemove = jest.fn();
151+
let {getAllByRole, getAllByText} = render(
152+
<TagGroup
153+
label="Amenities"
154+
onRemove={onRemove}>
155+
<Item key="laundry">Laundry</Item>
156+
<Item key="fitness">Fitness center</Item>
157+
<Item key="parking">Parking</Item>
158+
</TagGroup>
159+
);
160+
161+
let tags = getAllByRole('row');
162+
let removeButtons = getAllByText('x');
163+
expect(removeButtons).toHaveLength(3);
164+
165+
// Tab to focus the first tag
166+
await user.tab();
167+
expect(document.activeElement).toBe(tags[0]);
168+
169+
// Tab to focus the first remove button
170+
await user.tab();
171+
expect(document.activeElement).toBe(removeButtons[0]);
172+
173+
// Test remove button functionality
174+
await user.keyboard(' '); // Press space to activate button
175+
expect(onRemove).toHaveBeenCalledTimes(1);
176+
expect(onRemove).toHaveBeenLastCalledWith(new Set(['laundry']));
177+
});
178+
179+
it('should support keyboard selection while navigating between tags', async () => {
180+
let onSelectionChange = jest.fn();
181+
let {getAllByRole} = render(
182+
<TagGroup
183+
label="Amenities"
184+
selectionMode="multiple"
185+
onSelectionChange={onSelectionChange}>
186+
<Item key="laundry">Laundry</Item>
187+
<Item key="fitness">Fitness center</Item>
188+
<Item key="parking">Parking</Item>
189+
</TagGroup>
190+
);
191+
192+
let tags = getAllByRole('row');
193+
194+
// Tab to focus the first tag
195+
await user.tab();
196+
expect(document.activeElement).toBe(tags[0]);
197+
198+
// Press space to select the first tag
199+
await user.keyboard(' ');
200+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
201+
202+
// Move to the second tag using arrow key
203+
await user.keyboard('{ArrowRight}');
204+
expect(document.activeElement).toBe(tags[1]);
205+
206+
// Press space to select the second tag
207+
await user.keyboard(' ');
208+
expect(onSelectionChange).toHaveBeenCalledTimes(2);
209+
210+
// Move to the third tag
211+
await user.keyboard('{ArrowRight}');
212+
expect(document.activeElement).toBe(tags[2]);
213+
});
115214
});

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

+12-5
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {action} from '@storybook/addon-actions';
1314
import {Button, Checkbox, CheckboxProps, DropIndicator, GridLayout, GridList, GridListItem, GridListItemProps, ListLayout, Size, Tag, TagGroup, TagList, useDragAndDrop, Virtualizer} from 'react-aria-components';
1415
import {classNames} from '@react-spectrum/utils';
1516
import React from 'react';
1617
import styles from '../example/index.css';
1718
import {useListData} from 'react-stately';
18-
1919
export default {
2020
title: 'React Aria Components'
2121
};
@@ -187,11 +187,18 @@ export function TagGroupInsideGridList() {
187187
}}>
188188
<MyGridListItem textValue="Tags">
189189
1,1
190-
<TagGroup aria-label="Tag group">
190+
<TagGroup aria-label="Tag group 1" onRemove={action('onRemove')}>
191191
<TagList style={{display: 'flex', gap: 10}}>
192-
<Tag key="1">Tag 1</Tag>
193-
<Tag key="2">Tag 2</Tag>
194-
<Tag key="3">Tag 3</Tag>
192+
<Tag key="1">Tag 1<Button slot="remove">X</Button></Tag>
193+
<Tag key="2">Tag 2<Button slot="remove">X</Button></Tag>
194+
<Tag key="3">Tag 3<Button slot="remove">X</Button></Tag>
195+
</TagList>
196+
</TagGroup>
197+
<TagGroup aria-label="Tag group 2" onRemove={action('onRemove')}>
198+
<TagList style={{display: 'flex', gap: 10}}>
199+
<Tag key="1">Tag 1<Button slot="remove">X</Button></Tag>
200+
<Tag key="2">Tag 2<Button slot="remove">X</Button></Tag>
201+
<Tag key="3">Tag 3<Button slot="remove">X</Button></Tag>
195202
</TagList>
196203
</TagGroup>
197204
</MyGridListItem>

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

+85-49
Original file line numberDiff line numberDiff line change
@@ -10,67 +10,103 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Label, OverlayArrow, Tag, TagGroup, TagGroupProps, TagList, TagProps, Tooltip, TooltipTrigger} from 'react-aria-components';
13+
import {action} from '@storybook/addon-actions';
14+
import {Button, Label, OverlayArrow, Tag, TagGroup, TagGroupProps, TagList, TagProps, Tooltip, TooltipTrigger} from 'react-aria-components';
15+
import {Meta, StoryObj} from '@storybook/react';
1416
import React from 'react';
1517

16-
export default {
17-
title: 'React Aria Components'
18-
};
19-
20-
export const TagGroupExample = (props: TagGroupProps) => (
21-
<TagGroup {...props}>
22-
<Label>Categories</Label>
23-
<TagList style={{display: 'flex', gap: 4}}>
24-
<MyTag href="https://nytimes.com">News</MyTag>
25-
<MyTag>Travel</MyTag>
26-
<MyTag>Gaming</MyTag>
27-
<TooltipTrigger>
28-
<MyTag>Shopping</MyTag>
29-
<Tooltip
30-
offset={5}
31-
style={{
32-
background: 'Canvas',
33-
color: 'CanvasText',
34-
border: '1px solid gray',
35-
padding: 5,
36-
borderRadius: 4
37-
}}>
38-
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
39-
<svg width="8" height="8" style={{display: 'block'}}>
40-
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
41-
</svg>
42-
</OverlayArrow>
43-
I am a tooltip
44-
</Tooltip>
45-
</TooltipTrigger>
46-
</TagList>
47-
</TagGroup>
48-
);
49-
50-
TagGroupExample.args = {
51-
selectionMode: 'none',
52-
selectionBehavior: 'toggle'
53-
};
54-
55-
TagGroupExample.argTypes = {
56-
selectionMode: {
57-
control: {
58-
type: 'inline-radio',
59-
options: ['none', 'single', 'multiple']
60-
}
18+
const meta: Meta<typeof TagGroup> = {
19+
title: 'React Aria Components',
20+
component: TagGroup,
21+
args: {
22+
selectionMode: 'none',
23+
selectionBehavior: 'toggle'
6124
},
62-
selectionBehavior: {
63-
control: {
64-
type: 'inline-radio',
25+
argTypes: {
26+
selectionMode: {
27+
control: 'inline-radio',
28+
options: ['none', 'single', 'multiple']
29+
},
30+
selectionBehavior: {
31+
control: 'inline-radio',
6532
options: ['toggle', 'replace']
6633
}
6734
}
6835
};
6936

37+
export default meta;
38+
type Story = StoryObj<typeof TagGroup>;
39+
40+
export const TagGroupExample: Story = {
41+
render: (props: TagGroupProps) => (
42+
<TagGroup {...props}>
43+
<Label>Categories</Label>
44+
<TagList style={{display: 'flex', gap: 4}}>
45+
<MyTag href="https://nytimes.com">News</MyTag>
46+
<MyTag>Travel</MyTag>
47+
<MyTag>Gaming</MyTag>
48+
<TooltipTrigger>
49+
<MyTag>Shopping</MyTag>
50+
<Tooltip
51+
offset={5}
52+
style={{
53+
background: 'Canvas',
54+
color: 'CanvasText',
55+
border: '1px solid gray',
56+
padding: 5,
57+
borderRadius: 4
58+
}}>
59+
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
60+
<svg width="8" height="8" style={{display: 'block'}}>
61+
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
62+
</svg>
63+
</OverlayArrow>
64+
I am a tooltip
65+
</Tooltip>
66+
</TooltipTrigger>
67+
</TagList>
68+
</TagGroup>
69+
)
70+
};
71+
72+
7073
function MyTag(props: TagProps) {
7174
return (
7275
<Tag
7376
{...props}
7477
style={({isSelected}) => ({border: '1px solid gray', borderRadius: 4, padding: '0 4px', background: isSelected ? 'black' : '', color: isSelected ? 'white' : '', cursor: props.href ? 'pointer' : 'default'})} />
7578
);
7679
}
80+
81+
82+
export const TagGroupExampleWithRemove: Story = {
83+
render: (props: TagGroupProps) => (
84+
<TagGroup {...props} onRemove={action('onRemove')}>
85+
<Label>Categories</Label>
86+
<TagList style={{display: 'flex', gap: 4}}>
87+
<MyTag>Marsupial<Button slot="remove">X</Button></MyTag>
88+
<MyTag>Animal<Button slot="remove">X</Button></MyTag>
89+
<MyTag>Mammal<Button slot="remove">X</Button></MyTag>
90+
<TooltipTrigger>
91+
<MyTag>Chordate<Button slot="remove">X</Button></MyTag>
92+
<Tooltip
93+
offset={5}
94+
style={{
95+
background: 'Canvas',
96+
color: 'CanvasText',
97+
border: '1px solid gray',
98+
padding: 5,
99+
borderRadius: 4
100+
}}>
101+
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
102+
<svg width="8" height="8" style={{display: 'block'}}>
103+
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
104+
</svg>
105+
</OverlayArrow>
106+
I am a tooltip
107+
</Tooltip>
108+
</TooltipTrigger>
109+
</TagList>
110+
</TagGroup>
111+
)
112+
};

0 commit comments

Comments
 (0)