Skip to content

Commit fb79bc0

Browse files
reidbarbersnowystingerLFDanLu
authored
TagGroup: add default empty state and support for custom empty state (#4358)
* add empty state and focus ring * add tests * copy * remove container negative margin if empty * fix styles for focus ring * update stories * improve stories * fix story * add chromatic stories * use translated string for None * move default to prop destructuring * fix focus-visible style and add comment * add min-height to empty state * handle error on removing all tags with maxRows * focus container after last tag removed * add ar-AE string for chromatic * switch to useEffect * update prevCount --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Daniel Lu <[email protected]>
1 parent f89a1b4 commit fb79bc0

File tree

9 files changed

+204
-31
lines changed

9 files changed

+204
-31
lines changed

packages/@adobe/spectrum-css-temp/components/tags/index.css

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,25 @@ governing permissions and limitations under the License.
1818

1919
.spectrum-Tags {
2020
display: inline;
21+
22+
&:focus-visible {
23+
outline: none;
24+
}
25+
26+
&.focus-ring {
27+
outline: none;
28+
box-shadow: 0 0 0 2px var(--spectrum-tag-border-color-key-focus);
29+
/* Allows us to use a box-shadow for the focus ring. Should not cause layout shifts since it is within a container. */
30+
display: block;
31+
}
2132
}
2233

2334
.spectrum-Tags-container {
24-
/* Aligns tags with label. */
25-
margin-inline-start: calc(calc(var(--spectrum-taggroup-tag-gap-x) / 2) * -1);
26-
margin-inline-end: calc(var(--spectrum-taggroup-tag-gap-x) / 2);
35+
&:not(.spectrum-Tags-container--empty) {
36+
/* Aligns tags with label. */
37+
margin-inline-start: calc(calc(var(--spectrum-taggroup-tag-gap-x) / 2) * -1);
38+
margin-inline-end: calc(var(--spectrum-taggroup-tag-gap-x) / 2);
39+
}
2740
}
2841

2942
.spectrum-Tag {
@@ -126,3 +139,7 @@ governing permissions and limitations under the License.
126139
display: grid;
127140
}
128141
}
142+
143+
.spectrum-Tags-empty-state {
144+
min-height: var(--spectrum-tag-height);
145+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function useTag<T>(props: AriaTagProps<T>, state: ListState<T>, ref: RefO
6565
e.preventDefault();
6666
}
6767
};
68-
68+
6969
let modality: string = useInteractionModality();
7070
if (modality === 'virtual' && (typeof window !== 'undefined' && 'ontouchstart' in window)) {
7171
modality = 'touch';

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {AriaLabelingProps, DOMAttributes, DOMProps, Validation} from '@react-types/shared';
1414
import {filterDOMProps, mergeProps} from '@react-aria/utils';
1515
import type {ListState} from '@react-stately/list';
16-
import {RefObject, useState} from 'react';
16+
import {RefObject, useEffect, useRef, useState} from 'react';
1717
import {TagGroupProps} from '@react-types/tag';
1818
import {TagKeyboardDelegate} from './TagKeyboardDelegate';
1919
import {useField} from '@react-aria/label';
@@ -53,14 +53,21 @@ export function useTagGroup<T>(props: AriaTagGroupProps<T>, state: ListState<T>,
5353
let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField(props);
5454
let {gridProps} = useGridList({...props, ...fieldProps, keyboardDelegate}, state, ref);
5555

56-
// Don't want the grid to be focusable or accessible via keyboard
57-
delete gridProps.tabIndex;
58-
5956
let [isFocusWithin, setFocusWithin] = useState(false);
6057
let {focusWithinProps} = useFocusWithin({
6158
onFocusWithinChange: setFocusWithin
6259
});
6360
let domProps = filterDOMProps(props);
61+
62+
// If the last tag is removed, focus the container.
63+
let prevCount = useRef(state.collection.size);
64+
useEffect(() => {
65+
if (prevCount.current > 0 && state.collection.size === 0 && isFocusWithin) {
66+
ref.current.focus();
67+
}
68+
prevCount.current = state.collection.size;
69+
}, [state.collection.size, isFocusWithin, ref]);
70+
6471
return {
6572
gridProps: mergeProps(gridProps, domProps, {
6673
role: state.collection.size ? 'grid' : null,

packages/@react-spectrum/tag/chromatic/TagGroup.chromatic.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import Audio from '@spectrum-icons/workflow/Audio';
1414
import {ComponentMeta, ComponentStoryObj} from '@storybook/react';
1515
import {Item, TagGroup} from '../';
16+
import {Link} from '@react-spectrum/link';
1617
import React from 'react';
1718
import {Text} from '@react-spectrum/text';
1819

@@ -124,3 +125,19 @@ export const MaxRowsCustomAction: TagGroupStory = {
124125
]
125126
};
126127

128+
export const EmptyState: TagGroupStory = {
129+
render: (args) => (
130+
<TagGroup label="Tag group with empty state" {...args}>
131+
{[]}
132+
</TagGroup>
133+
),
134+
storyName: 'Empty state'
135+
};
136+
137+
export const CustomEmptyState: TagGroupStory = {
138+
...EmptyState,
139+
args: {
140+
renderEmptyState: () => <span>No tags. <Link><a href="//react-spectrum.com">Click here</a></Link> to add some.</span>
141+
},
142+
storyName: 'Custom empty state'
143+
};
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"actions": "الإجراءات",
33
"hideButtonLabel": "إظهار أقل",
4-
"showAllButtonLabel": "عرض الكل ({tagCount, number})"
4+
"showAllButtonLabel": "عرض الكل ({tagCount, number})",
5+
"noTags": "None"
56
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"showAllButtonLabel": "Show all ({tagCount, number})",
33
"hideButtonLabel": "Show less",
4-
"actions": "Actions"
4+
"actions": "Actions",
5+
"noTags": "None"
56
}

packages/@react-spectrum/tag/src/TagGroup.tsx

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {AriaTagGroupProps, TagKeyboardDelegate, useTagGroup} from '@react-aria/t
1515
import {classNames, useDOMRef} from '@react-spectrum/utils';
1616
import {DOMRef, SpectrumHelpTextProps, SpectrumLabelableProps, StyleProps} from '@react-types/shared';
1717
import {Field} from '@react-spectrum/label';
18-
import {FocusScope} from '@react-aria/focus';
18+
import {FocusRing, FocusScope} from '@react-aria/focus';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
2121
import {ListCollection, useListState} from '@react-stately/list';
@@ -31,7 +31,9 @@ export interface SpectrumTagGroupProps<T> extends Omit<AriaTagGroupProps<T>, 'ke
3131
/** The label to display on the action button. */
3232
actionLabel?: string,
3333
/** Handler that is called when the action button is pressed. */
34-
onAction?: () => void
34+
onAction?: () => void,
35+
/** Sets what the TagGroup should render when there are no tags to display. */
36+
renderEmptyState?: () => JSX.Element
3537
}
3638

3739
function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef<HTMLDivElement>) {
@@ -44,7 +46,8 @@ function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef
4446
children,
4547
actionLabel,
4648
onAction,
47-
labelPosition
49+
labelPosition,
50+
renderEmptyState = () => stringFormatter.format('noTags')
4851
} = props;
4952
let domRef = useDOMRef(ref);
5053
let containerRef = useRef(null);
@@ -76,6 +79,13 @@ function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef
7679

7780
let tags = [...currTagsRef.children];
7881
let buttons = [...currContainerRef.parentElement.querySelectorAll('button')];
82+
if (tags.length === 0 || buttons.length === 0) {
83+
return {
84+
visibleTagCount: 0,
85+
showCollapseButton: false,
86+
maxHeight: undefined
87+
};
88+
}
7989
let currY = -Infinity;
8090
let rowCount = 0;
8191
let index = 0;
@@ -150,6 +160,7 @@ function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef
150160
};
151161

152162
let showActions = tagState.showCollapseButton || (actionLabel && onAction);
163+
let isEmpty = state.collection.size === 0;
153164

154165
return (
155166
<FocusScope>
@@ -173,24 +184,39 @@ function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef
173184
<div
174185
style={maxRows != null && tagState.showCollapseButton && isCollapsed ? {maxHeight: tagState.maxHeight, overflow: 'hidden'} : undefined}
175186
ref={containerRef}
176-
className={classNames(styles, 'spectrum-Tags-container')}>
177-
<div
178-
ref={tagsRef}
179-
{...gridProps}
180-
className={classNames(styles, 'spectrum-Tags')}>
181-
{visibleTags.map(item => (
182-
<Tag
183-
{...item.props}
184-
key={item.key}
185-
item={item}
186-
state={state}
187-
allowsRemoving={allowsRemoving}
188-
onRemove={onRemove}>
189-
{item.rendered}
190-
</Tag>
191-
))}
192-
</div>
193-
{showActions &&
187+
className={
188+
classNames(
189+
styles,
190+
'spectrum-Tags-container',
191+
{
192+
'spectrum-Tags-container--empty': isEmpty
193+
}
194+
)
195+
}>
196+
<FocusRing focusRingClass={classNames(styles, 'focus-ring')}>
197+
<div
198+
ref={tagsRef}
199+
{...gridProps}
200+
className={classNames(styles, 'spectrum-Tags')}>
201+
{visibleTags.map(item => (
202+
<Tag
203+
{...item.props}
204+
key={item.key}
205+
item={item}
206+
state={state}
207+
allowsRemoving={allowsRemoving}
208+
onRemove={onRemove}>
209+
{item.rendered}
210+
</Tag>
211+
))}
212+
{isEmpty && (
213+
<div className={classNames(styles, 'spectrum-Tags-empty-state')}>
214+
{renderEmptyState()}
215+
</div>
216+
)}
217+
</div>
218+
</FocusRing>
219+
{showActions && !isEmpty &&
194220
<Provider isDisabled={false}>
195221
<div
196222
role="group"

packages/@react-spectrum/tag/stories/TagGroup.stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {Content} from '@react-spectrum/view';
1818
import {ContextualHelp} from '@react-spectrum/contextualhelp';
1919
import {Heading, Text} from '@react-spectrum/text';
2020
import {Item, SpectrumTagGroupProps, TagGroup} from '../src';
21+
import {Link} from '@react-spectrum/link';
2122
import React, {useState} from 'react';
2223

2324
let manyItems = [];
@@ -236,6 +237,23 @@ export const WithLabelDescriptionContextualHelpAndAction: TagGroupStory = {
236237
name: 'with label, description, contextual help + action'
237238
};
238239

240+
export const EmptyState: TagGroupStory = {
241+
render: (args) => (
242+
<TagGroup label="Tag group with empty state" {...args}>
243+
{[]}
244+
</TagGroup>
245+
),
246+
storyName: 'Empty state'
247+
};
248+
249+
export const CustomEmptyState: TagGroupStory = {
250+
...EmptyState,
251+
args: {
252+
renderEmptyState: () => <span>No tags. <Link><a href="//react-spectrum.com">Click here</a></Link> to add some.</span>
253+
},
254+
storyName: 'Custom empty state'
255+
};
256+
239257
function OnRemoveExample(props) {
240258
let {withAvatar, ...otherProps} = props;
241259
let [items, setItems] = useState([

packages/@react-spectrum/tag/test/TagGroup.test.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {act, fireEvent, mockImplementation, render, triggerPress, within} from '
1414
import {Button} from '@react-spectrum/button';
1515
import {chain} from '@react-aria/utils';
1616
import {Item} from '@react-stately/collections';
17+
import {Link} from '@react-spectrum/link';
1718
import {Provider} from '@react-spectrum/provider';
1819
import React from 'react';
1920
import {TagGroup} from '../src';
@@ -418,6 +419,58 @@ describe('TagGroup', function () {
418419
expect(document.activeElement).toBe(tags[1]);
419420
});
420421

422+
it.each`
423+
Name | props
424+
${'on `Delete` keypress'} | ${{keyPress: 'Delete'}}
425+
${'on `Backspace` keypress'} | ${{keyPress: 'Backspace'}}
426+
`('Should focus container after last tag is removed $Name', function ({Name, props}) {
427+
428+
function TagGroupWithDelete(props) {
429+
let [items, setItems] = React.useState([
430+
{id: 1, label: 'Cool Tag 1'},
431+
{id: 2, label: 'Another cool tag'}
432+
]);
433+
434+
let removeItem = (key) => {
435+
setItems(prevItems => prevItems.filter((item) => key !== item.id));
436+
};
437+
438+
return (
439+
<Provider theme={theme}>
440+
<TagGroup items={items} aria-label="tag group" allowsRemoving onRemove={chain(removeItem, onRemoveSpy)} {...props}>
441+
{item => <Item>{item.label}</Item>}
442+
</TagGroup>
443+
</Provider>
444+
);
445+
}
446+
447+
let {getAllByRole, getByRole, queryAllByRole} = render(
448+
<TagGroupWithDelete {...props} />
449+
);
450+
451+
let tags = getAllByRole('row');
452+
let container = getByRole('grid');
453+
userEvent.tab();
454+
expect(document.activeElement).toBe(tags[0]);
455+
fireEvent.keyDown(document.activeElement, {key: props.keyPress});
456+
fireEvent.keyUp(document.activeElement, {key: props.keyPress});
457+
expect(onRemoveSpy).toHaveBeenCalledTimes(1);
458+
expect(onRemoveSpy).toHaveBeenCalledWith(1);
459+
460+
tags = getAllByRole('row');
461+
expect(document.activeElement).toBe(tags[0]);
462+
fireEvent.keyDown(document.activeElement, {key: props.keyPress});
463+
fireEvent.keyUp(document.activeElement, {key: props.keyPress});
464+
expect(onRemoveSpy).toHaveBeenCalledTimes(2);
465+
expect(onRemoveSpy).toHaveBeenCalledWith(2);
466+
467+
act(() => jest.runAllTimers());
468+
469+
tags = queryAllByRole('row');
470+
expect(tags.length).toBe(0);
471+
expect(document.activeElement).toBe(container);
472+
});
473+
421474
it('maxRows should limit the number of tags shown', function () {
422475
let offsetWidth = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
423476
.mockImplementationOnce(() => ({x: 200, y: 300, width: 75, height: 32, top: 300, right: 275, bottom: 335, left: 200}))
@@ -642,4 +695,37 @@ describe('TagGroup', function () {
642695

643696
computedStyles.mockReset();
644697
});
698+
699+
700+
it('should render empty state', async function () {
701+
let {getByText} = render(
702+
<Provider theme={theme}>
703+
<TagGroup aria-label="tag group">
704+
{[]}
705+
</TagGroup>
706+
</Provider>
707+
);
708+
await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings
709+
expect(getByText('None')).toBeTruthy();
710+
});
711+
712+
it('should allow you to tab into TagGroup if empty with link', async function () {
713+
let computedStyles = jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({marginRight: '4px', marginTop: '4px', height: '24px'}));
714+
715+
let renderEmptyState = () => (
716+
<span>No tags. <Link><a href="//react-spectrum.com">Click here</a></Link> to add some.</span>
717+
);
718+
let {getByRole} = render(
719+
<Provider theme={theme}>
720+
<TagGroup aria-label="tag group" renderEmptyState={renderEmptyState}>
721+
{[]}
722+
</TagGroup>
723+
</Provider>
724+
);
725+
await act(() => Promise.resolve());
726+
let link = getByRole('link');
727+
userEvent.tab();
728+
expect(document.activeElement).toBe(link);
729+
computedStyles.mockReset();
730+
});
645731
});

0 commit comments

Comments
 (0)