Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: Enable tabbing to tag remove buttons #8002

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
5 changes: 1 addition & 4 deletions packages/@react-aria/tag/src/useTag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
node: item
}, state, ref);

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

Expand Down Expand Up @@ -103,8 +101,7 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
'aria-labelledby': `${buttonId} ${rowProps.id}`,
isDisabled,
id: buttonId,
onPress: () => onRemove ? onRemove(new Set([item.key])) : null,
excludeFromTabOrder: true
onPress: () => onRemove ? onRemove(new Set([item.key])) : null
},
rowProps: mergeProps(focusableProps, rowProps, domProps, linkProps, {
tabIndex,
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/tag/src/useTagGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export function useTagGroup<T>(props: AriaTagGroupOptions<T>, state: ListState<T
...fieldProps,
keyboardDelegate,
shouldFocusWrap: true,
linkBehavior: 'override'
linkBehavior: 'override',
keyboardNavigationBehavior: 'tab'
}, state, ref);

let [isFocusWithin, setFocusWithin] = useState(false);
Expand Down
99 changes: 99 additions & 0 deletions packages/@react-aria/tag/test/useTagGroup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,103 @@ describe('useTagGroup', function () {
expect(onRemove).toHaveBeenCalledTimes(3);
expect(onRemove).toHaveBeenLastCalledWith(new Set(['pool']));
});

it('should support tabbing to tags and arrow navigation between tags', async () => {
let {getAllByRole} = render(
<TagGroup
label="Amenities">
<Item key="laundry">Laundry</Item>
<Item key="fitness">Fitness center</Item>
<Item key="parking">Parking</Item>
</TagGroup>
);

let tags = getAllByRole('row');
expect(tags).toHaveLength(3);

// Initially, nothing should be focused
expect(document.activeElement).not.toBe(tags[0]);

// Tab to focus the first tag
await user.tab();
expect(document.activeElement).toBe(tags[0]);

// Check that we can focus each tag with keyboard
// Instead of using tab, let's verify we can move with arrow keys
await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(tags[1]);

await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(tags[2]);

// Test we can go back
await user.keyboard('{ArrowLeft}');
expect(document.activeElement).toBe(tags[1]);
});

it('should support tabbing to remove buttons', async () => {
let onRemove = jest.fn();
let {getAllByRole, getAllByText} = render(
<TagGroup
label="Amenities"
onRemove={onRemove}>
<Item key="laundry">Laundry</Item>
<Item key="fitness">Fitness center</Item>
<Item key="parking">Parking</Item>
</TagGroup>
);

let tags = getAllByRole('row');
let removeButtons = getAllByText('x');
expect(removeButtons).toHaveLength(3);

// Tab to focus the first tag
await user.tab();
expect(document.activeElement).toBe(tags[0]);

// Tab to focus the first remove button
await user.tab();
expect(document.activeElement).toBe(removeButtons[0]);

// Test remove button functionality
await user.keyboard(' '); // Press space to activate button
expect(onRemove).toHaveBeenCalledTimes(1);
expect(onRemove).toHaveBeenLastCalledWith(new Set(['laundry']));
});

it('should support keyboard selection while navigating between tags', async () => {
let onSelectionChange = jest.fn();
let {getAllByRole} = render(
<TagGroup
label="Amenities"
selectionMode="multiple"
onSelectionChange={onSelectionChange}>
<Item key="laundry">Laundry</Item>
<Item key="fitness">Fitness center</Item>
<Item key="parking">Parking</Item>
</TagGroup>
);

let tags = getAllByRole('row');

// Tab to focus the first tag
await user.tab();
expect(document.activeElement).toBe(tags[0]);

// Press space to select the first tag
await user.keyboard(' ');
expect(onSelectionChange).toHaveBeenCalledTimes(1);

// Move to the second tag using arrow key
await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(tags[1]);

// Press space to select the second tag
await user.keyboard(' ');
expect(onSelectionChange).toHaveBeenCalledTimes(2);

// Move to the third tag
await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(tags[2]);
});
});
14 changes: 7 additions & 7 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@
* governing permissions and limitations under the License.
*/

import {action} from '@storybook/addon-actions';
import {Button, Checkbox, CheckboxProps, DropIndicator, GridLayout, GridList, GridListItem, GridListItemProps, ListLayout, Size, Tag, TagGroup, TagList, useDragAndDrop, Virtualizer} from 'react-aria-components';
import {classNames} from '@react-spectrum/utils';
import React from 'react';
import styles from '../example/index.css';
import {useListData} from 'react-stately';

export default {
title: 'React Aria Components'
};
Expand Down Expand Up @@ -158,7 +158,7 @@ export function VirtualizedGridListGrid() {
}

return (
<Virtualizer
<Virtualizer
layout={GridLayout}
layoutOptions={{
minItemSize: new Size(40, 40)
Expand All @@ -182,19 +182,19 @@ export function TagGroupInsideGridList() {
}}>
<MyGridListItem textValue="Tags">
1,1
<TagGroup aria-label="Tag group">
<TagGroup aria-label="Tag group" onRemove={action('onRemove')}>
<TagList style={{display: 'flex', gap: 10}}>
<Tag key="1">Tag 1</Tag>
<Tag key="2">Tag 2</Tag>
<Tag key="3">Tag 3</Tag>
<Tag key="1">Tag 1<Button slot="remove">X</Button></Tag>
<Tag key="2">Tag 2<Button slot="remove">X</Button></Tag>
<Tag key="3">Tag 3<Button slot="remove">X</Button></Tag>
</TagList>
</TagGroup>
</MyGridListItem>
<MyGridListItem>
1,2 <Button>Actions</Button>
</MyGridListItem>
<MyGridListItem>
1,3
1,3
<TagGroup aria-label="Tag group">
<TagList style={{display: 'flex', gap: 10}}>
<Tag key="1">Tag 1</Tag>
Expand Down
134 changes: 85 additions & 49 deletions packages/react-aria-components/stories/TagGroup.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,67 +10,103 @@
* governing permissions and limitations under the License.
*/

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

export default {
title: 'React Aria Components'
};

export const TagGroupExample = (props: TagGroupProps) => (
<TagGroup {...props}>
<Label>Categories</Label>
<TagList style={{display: 'flex', gap: 4}}>
<MyTag href="https://nytimes.com">News</MyTag>
<MyTag>Travel</MyTag>
<MyTag>Gaming</MyTag>
<TooltipTrigger>
<MyTag>Shopping</MyTag>
<Tooltip
offset={5}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 5,
borderRadius: 4
}}>
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
<svg width="8" height="8" style={{display: 'block'}}>
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>
I am a tooltip
</Tooltip>
</TooltipTrigger>
</TagList>
</TagGroup>
);

TagGroupExample.args = {
selectionMode: 'none',
selectionBehavior: 'toggle'
};

TagGroupExample.argTypes = {
selectionMode: {
control: {
type: 'inline-radio',
options: ['none', 'single', 'multiple']
}
const meta: Meta<typeof TagGroup> = {
title: 'React Aria Components',
component: TagGroup,
args: {
selectionMode: 'none',
selectionBehavior: 'toggle'
},
selectionBehavior: {
control: {
type: 'inline-radio',
argTypes: {
selectionMode: {
control: 'inline-radio',
options: ['none', 'single', 'multiple']
},
selectionBehavior: {
control: 'inline-radio',
options: ['toggle', 'replace']
}
}
};

export default meta;
type Story = StoryObj<typeof TagGroup>;

export const TagGroupExample: Story = {
render: (props: TagGroupProps) => (
<TagGroup {...props}>
<Label>Categories</Label>
<TagList style={{display: 'flex', gap: 4}}>
<MyTag href="https://nytimes.com">News</MyTag>
<MyTag>Travel</MyTag>
<MyTag>Gaming</MyTag>
<TooltipTrigger>
<MyTag>Shopping</MyTag>
<Tooltip
offset={5}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 5,
borderRadius: 4
}}>
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
<svg width="8" height="8" style={{display: 'block'}}>
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>
I am a tooltip
</Tooltip>
</TooltipTrigger>
</TagList>
</TagGroup>
)
};


function MyTag(props: TagProps) {
return (
<Tag
{...props}
style={({isSelected}) => ({border: '1px solid gray', borderRadius: 4, padding: '0 4px', background: isSelected ? 'black' : '', color: isSelected ? 'white' : '', cursor: props.href ? 'pointer' : 'default'})} />
);
}


export const TagGroupExampleWithRemove: Story = {
render: (props: TagGroupProps) => (
<TagGroup {...props} onRemove={action('onRemove')}>
<Label>Categories</Label>
<TagList style={{display: 'flex', gap: 4}}>
<MyTag>Marsupial<Button slot="remove">X</Button></MyTag>
<MyTag>Animal<Button slot="remove">X</Button></MyTag>
<MyTag>Mammal<Button slot="remove">X</Button></MyTag>
<TooltipTrigger>
<MyTag>Chordate<Button slot="remove">X</Button></MyTag>
<Tooltip
offset={5}
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 5,
borderRadius: 4
}}>
<OverlayArrow style={{transform: 'translateX(-50%)'}}>
<svg width="8" height="8" style={{display: 'block'}}>
<path d="M0 0L4 4L8 0" fill="white" strokeWidth={1} stroke="gray" />
</svg>
</OverlayArrow>
I am a tooltip
</Tooltip>
</TooltipTrigger>
</TagList>
</TagGroup>
)
};
Loading