diff --git a/packages/@adobe/spectrum-css-temp/components/tags/index.css b/packages/@adobe/spectrum-css-temp/components/tags/index.css index cdfad680be7..b7468f62777 100644 --- a/packages/@adobe/spectrum-css-temp/components/tags/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tags/index.css @@ -18,12 +18,25 @@ governing permissions and limitations under the License. .spectrum-Tags { display: inline; + + &:focus-visible { + outline: none; + } + + &.focus-ring { + outline: none; + box-shadow: 0 0 0 2px var(--spectrum-tag-border-color-key-focus); + /* Allows us to use a box-shadow for the focus ring. Should not cause layout shifts since it is within a container. */ + display: block; + } } .spectrum-Tags-container { - /* Aligns tags with label. */ - margin-inline-start: calc(calc(var(--spectrum-taggroup-tag-gap-x) / 2) * -1); - margin-inline-end: calc(var(--spectrum-taggroup-tag-gap-x) / 2); + &:not(.spectrum-Tags-container--empty) { + /* Aligns tags with label. */ + margin-inline-start: calc(calc(var(--spectrum-taggroup-tag-gap-x) / 2) * -1); + margin-inline-end: calc(var(--spectrum-taggroup-tag-gap-x) / 2); + } } .spectrum-Tag { @@ -126,3 +139,7 @@ governing permissions and limitations under the License. display: grid; } } + +.spectrum-Tags-empty-state { + min-height: var(--spectrum-tag-height); +} diff --git a/packages/@react-aria/tag/src/useTag.ts b/packages/@react-aria/tag/src/useTag.ts index b7f7806147a..4371bb60e4b 100644 --- a/packages/@react-aria/tag/src/useTag.ts +++ b/packages/@react-aria/tag/src/useTag.ts @@ -65,7 +65,7 @@ export function useTag(props: AriaTagProps, state: ListState, ref: RefO e.preventDefault(); } }; - + let modality: string = useInteractionModality(); if (modality === 'virtual' && (typeof window !== 'undefined' && 'ontouchstart' in window)) { modality = 'touch'; diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index 76bb5855b23..be565052e37 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, DOMAttributes, DOMProps, Validation} from '@react-types/shared'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; import type {ListState} from '@react-stately/list'; -import {RefObject, useState} from 'react'; +import {RefObject, useEffect, useRef, useState} from 'react'; import {TagGroupProps} from '@react-types/tag'; import {TagKeyboardDelegate} from './TagKeyboardDelegate'; import {useField} from '@react-aria/label'; @@ -53,14 +53,21 @@ export function useTagGroup(props: AriaTagGroupProps, state: ListState, let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField(props); let {gridProps} = useGridList({...props, ...fieldProps, keyboardDelegate}, state, ref); - // Don't want the grid to be focusable or accessible via keyboard - delete gridProps.tabIndex; - let [isFocusWithin, setFocusWithin] = useState(false); let {focusWithinProps} = useFocusWithin({ onFocusWithinChange: setFocusWithin }); let domProps = filterDOMProps(props); + + // If the last tag is removed, focus the container. + let prevCount = useRef(state.collection.size); + useEffect(() => { + if (prevCount.current > 0 && state.collection.size === 0 && isFocusWithin) { + ref.current.focus(); + } + prevCount.current = state.collection.size; + }, [state.collection.size, isFocusWithin, ref]); + return { gridProps: mergeProps(gridProps, domProps, { role: state.collection.size ? 'grid' : null, diff --git a/packages/@react-spectrum/tag/chromatic/TagGroup.chromatic.tsx b/packages/@react-spectrum/tag/chromatic/TagGroup.chromatic.tsx index c771e21a80e..83ead7762e6 100644 --- a/packages/@react-spectrum/tag/chromatic/TagGroup.chromatic.tsx +++ b/packages/@react-spectrum/tag/chromatic/TagGroup.chromatic.tsx @@ -13,6 +13,7 @@ import Audio from '@spectrum-icons/workflow/Audio'; import {ComponentMeta, ComponentStoryObj} from '@storybook/react'; import {Item, TagGroup} from '../'; +import {Link} from '@react-spectrum/link'; import React from 'react'; import {Text} from '@react-spectrum/text'; @@ -124,3 +125,19 @@ export const MaxRowsCustomAction: TagGroupStory = { ] }; +export const EmptyState: TagGroupStory = { + render: (args) => ( + + {[]} + + ), + storyName: 'Empty state' +}; + +export const CustomEmptyState: TagGroupStory = { + ...EmptyState, + args: { + renderEmptyState: () => No tags. Click here to add some. + }, + storyName: 'Custom empty state' +}; diff --git a/packages/@react-spectrum/tag/intl/ar-AE.json b/packages/@react-spectrum/tag/intl/ar-AE.json index 85eff99f69b..6939cff6c6d 100644 --- a/packages/@react-spectrum/tag/intl/ar-AE.json +++ b/packages/@react-spectrum/tag/intl/ar-AE.json @@ -1,5 +1,6 @@ { "actions": "الإجراءات", "hideButtonLabel": "إظهار أقل", - "showAllButtonLabel": "عرض الكل ({tagCount, number})" + "showAllButtonLabel": "عرض الكل ({tagCount, number})", + "noTags": "None" } diff --git a/packages/@react-spectrum/tag/intl/en-US.json b/packages/@react-spectrum/tag/intl/en-US.json index 4c03c6aaf71..e078154e56d 100644 --- a/packages/@react-spectrum/tag/intl/en-US.json +++ b/packages/@react-spectrum/tag/intl/en-US.json @@ -1,5 +1,6 @@ { "showAllButtonLabel": "Show all ({tagCount, number})", "hideButtonLabel": "Show less", - "actions": "Actions" + "actions": "Actions", + "noTags": "None" } diff --git a/packages/@react-spectrum/tag/src/TagGroup.tsx b/packages/@react-spectrum/tag/src/TagGroup.tsx index 8b21d85866f..9b727897e91 100644 --- a/packages/@react-spectrum/tag/src/TagGroup.tsx +++ b/packages/@react-spectrum/tag/src/TagGroup.tsx @@ -15,7 +15,7 @@ import {AriaTagGroupProps, TagKeyboardDelegate, useTagGroup} from '@react-aria/t import {classNames, useDOMRef} from '@react-spectrum/utils'; import {DOMRef, SpectrumHelpTextProps, SpectrumLabelableProps, StyleProps} from '@react-types/shared'; import {Field} from '@react-spectrum/label'; -import {FocusScope} from '@react-aria/focus'; +import {FocusRing, FocusScope} from '@react-aria/focus'; // @ts-ignore import intlMessages from '../intl/*.json'; import {ListCollection, useListState} from '@react-stately/list'; @@ -31,7 +31,9 @@ export interface SpectrumTagGroupProps extends Omit, 'ke /** The label to display on the action button. */ actionLabel?: string, /** Handler that is called when the action button is pressed. */ - onAction?: () => void + onAction?: () => void, + /** Sets what the TagGroup should render when there are no tags to display. */ + renderEmptyState?: () => JSX.Element } function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef) { @@ -44,7 +46,8 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef children, actionLabel, onAction, - labelPosition + labelPosition, + renderEmptyState = () => stringFormatter.format('noTags') } = props; let domRef = useDOMRef(ref); let containerRef = useRef(null); @@ -76,6 +79,13 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef let tags = [...currTagsRef.children]; let buttons = [...currContainerRef.parentElement.querySelectorAll('button')]; + if (tags.length === 0 || buttons.length === 0) { + return { + visibleTagCount: 0, + showCollapseButton: false, + maxHeight: undefined + }; + } let currY = -Infinity; let rowCount = 0; let index = 0; @@ -150,6 +160,7 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef }; let showActions = tagState.showCollapseButton || (actionLabel && onAction); + let isEmpty = state.collection.size === 0; return ( @@ -173,24 +184,39 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef
-
- {visibleTags.map(item => ( - - {item.rendered} - - ))} -
- {showActions && + className={ + classNames( + styles, + 'spectrum-Tags-container', + { + 'spectrum-Tags-container--empty': isEmpty + } + ) + }> + +
+ {visibleTags.map(item => ( + + {item.rendered} + + ))} + {isEmpty && ( +
+ {renderEmptyState()} +
+ )} +
+
+ {showActions && !isEmpty &&
( + + {[]} + + ), + storyName: 'Empty state' +}; + +export const CustomEmptyState: TagGroupStory = { + ...EmptyState, + args: { + renderEmptyState: () => No tags. Click here to add some. + }, + storyName: 'Custom empty state' +}; + function OnRemoveExample(props) { let {withAvatar, ...otherProps} = props; let [items, setItems] = useState([ diff --git a/packages/@react-spectrum/tag/test/TagGroup.test.js b/packages/@react-spectrum/tag/test/TagGroup.test.js index c1269d1223c..76455bc5c30 100644 --- a/packages/@react-spectrum/tag/test/TagGroup.test.js +++ b/packages/@react-spectrum/tag/test/TagGroup.test.js @@ -14,6 +14,7 @@ import {act, fireEvent, mockImplementation, render, triggerPress, within} from ' import {Button} from '@react-spectrum/button'; import {chain} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; +import {Link} from '@react-spectrum/link'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {TagGroup} from '../src'; @@ -418,6 +419,58 @@ describe('TagGroup', function () { expect(document.activeElement).toBe(tags[1]); }); + it.each` + Name | props + ${'on `Delete` keypress'} | ${{keyPress: 'Delete'}} + ${'on `Backspace` keypress'} | ${{keyPress: 'Backspace'}} + `('Should focus container after last tag is removed $Name', function ({Name, props}) { + + function TagGroupWithDelete(props) { + let [items, setItems] = React.useState([ + {id: 1, label: 'Cool Tag 1'}, + {id: 2, label: 'Another cool tag'} + ]); + + let removeItem = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); + }; + + return ( + + + {item => {item.label}} + + + ); + } + + let {getAllByRole, getByRole, queryAllByRole} = render( + + ); + + let tags = getAllByRole('row'); + let container = getByRole('grid'); + userEvent.tab(); + expect(document.activeElement).toBe(tags[0]); + fireEvent.keyDown(document.activeElement, {key: props.keyPress}); + fireEvent.keyUp(document.activeElement, {key: props.keyPress}); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); + expect(onRemoveSpy).toHaveBeenCalledWith(1); + + tags = getAllByRole('row'); + expect(document.activeElement).toBe(tags[0]); + fireEvent.keyDown(document.activeElement, {key: props.keyPress}); + fireEvent.keyUp(document.activeElement, {key: props.keyPress}); + expect(onRemoveSpy).toHaveBeenCalledTimes(2); + expect(onRemoveSpy).toHaveBeenCalledWith(2); + + act(() => jest.runAllTimers()); + + tags = queryAllByRole('row'); + expect(tags.length).toBe(0); + expect(document.activeElement).toBe(container); + }); + it('maxRows should limit the number of tags shown', function () { let offsetWidth = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect') .mockImplementationOnce(() => ({x: 200, y: 300, width: 75, height: 32, top: 300, right: 275, bottom: 335, left: 200})) @@ -642,4 +695,37 @@ describe('TagGroup', function () { computedStyles.mockReset(); }); + + + it('should render empty state', async function () { + let {getByText} = render( + + + {[]} + + + ); + await act(() => Promise.resolve()); // wait for MutationObserver in useHasTabbableChild or we get act warnings + expect(getByText('None')).toBeTruthy(); + }); + + it('should allow you to tab into TagGroup if empty with link', async function () { + let computedStyles = jest.spyOn(window, 'getComputedStyle').mockImplementation(() => ({marginRight: '4px', marginTop: '4px', height: '24px'})); + + let renderEmptyState = () => ( + No tags. Click here to add some. + ); + let {getByRole} = render( + + + {[]} + + + ); + await act(() => Promise.resolve()); + let link = getByRole('link'); + userEvent.tab(); + expect(document.activeElement).toBe(link); + computedStyles.mockReset(); + }); });