diff --git a/packages/@adobe/spectrum-css-temp/components/tags/index.css b/packages/@adobe/spectrum-css-temp/components/tags/index.css index 82cffe91b12..28d36d50e42 100644 --- a/packages/@adobe/spectrum-css-temp/components/tags/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tags/index.css @@ -13,7 +13,7 @@ governing permissions and limitations under the License. @import '../commons/index.css'; .spectrum-Tags { - display: inline-flex; + display: flex; flex-wrap: wrap; margin: 0; @@ -27,7 +27,7 @@ governing permissions and limitations under the License. --spectrum-focus-ring-border-size: var(--spectrum-tag-border-size); display: grid; - grid-template-columns: 1fr auto; + grid-template-columns: auto 1fr auto; grid-template-areas: "icon content action"; align-items: center; box-sizing: border-box; @@ -58,28 +58,36 @@ governing permissions and limitations under the License. height: calc(var(--spectrum-tag-height) - (2 * var(--spectrum-tag-border-size))); width: var(--spectrum-global-dimension-size-300); } -} -.spectrum-Tag-icon { - grid-area: icon; - margin-inline-end: var(--spectrum-global-dimension-size-100); -} + .spectrum-Tag-cell { + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + } -.spectrum-Tag-content { - grid-area: content; - block-size: 100%; - line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2)); - margin-inline-end: var(--spectrum-tag-padding-x); - flex: 1 1 auto; - font-size: var(--spectrum-tag-text-size); - cursor: default; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - outline: none; -} + .spectrum-Tag-icon { + grid-area: icon; + margin-inline-end: var(--spectrum-global-dimension-size-100); + } + + .spectrum-Tag-content { + grid-area: content; + line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2)); + margin-inline-end: var(--spectrum-tag-padding-x); + flex: 1 1 auto; + font-size: var(--spectrum-tag-text-size); + cursor: default; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + outline: none; + } -.tags-removable { - margin-inline-end: 0; + &.is-removable { + .spectrum-Tag-content { + margin-inline-end: 0; + } + } } diff --git a/packages/@react-aria/tag/intl/en-US.json b/packages/@react-aria/tag/intl/en-US.json index 67ff0e1312a..f7a6cb22c7c 100644 --- a/packages/@react-aria/tag/intl/en-US.json +++ b/packages/@react-aria/tag/intl/en-US.json @@ -1,3 +1,3 @@ { - "remove": "Remove" + "remove": "Press Space or Delete to remove tag." } diff --git a/packages/@react-aria/tag/package.json b/packages/@react-aria/tag/package.json index 7b7f54219c7..f8a69edc127 100644 --- a/packages/@react-aria/tag/package.json +++ b/packages/@react-aria/tag/package.json @@ -17,12 +17,12 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-aria/grid": "^3.5.2", + "@react-aria/gridlist": "^3.1.1", "@react-aria/i18n": "^3.6.3", "@react-aria/interactions": "^3.13.1", "@react-aria/utils": "^3.14.2", - "@react-stately/grid": "^3.4.2", - "@react-types/grid": "^3.1.5", + "@react-stately/tag": "3.0.0-alpha.1", + "@react-types/button": "^3.7.0", "@react-types/shared": "^3.16.0", "@react-types/tag": "3.0.0-beta.1", "@swc/helpers": "^0.4.14" diff --git a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts index 3a3ed95f5b8..3f78d483f10 100644 --- a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts +++ b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts @@ -10,25 +10,26 @@ * governing permissions and limitations under the License. */ -import {GridCollection} from '@react-types/grid'; -import {GridKeyboardDelegate} from '@react-aria/grid'; +import {Collection, Direction, KeyboardDelegate} from '@react-types/shared'; import {Key} from 'react'; -export class TagKeyboardDelegate extends GridKeyboardDelegate> { - getFirstKey() { - let key = this.collection.getFirstKey(); - let item = this.collection.getItem(key); +export class TagKeyboardDelegate implements KeyboardDelegate { + private collection: Collection; + private direction: Direction; - return [...item.childNodes][0].key; + constructor(collection: Collection, direction: Direction) { + this.collection = collection; + this.direction = direction; } - getLastKey() { - let key = this.collection.getLastKey(); - let item = this.collection.getItem(key); - - return [...item.childNodes][0].key; + getFirstKey() { + return this.collection.getFirstKey(); } + getLastKey() { + return this.collection.getLastKey(); + } + getKeyRightOf(key: Key) { return this.direction === 'rtl' ? this.getKeyAbove(key) : this.getKeyBelow(key); } @@ -43,27 +44,12 @@ export class TagKeyboardDelegate extends GridKeyboardDelegate extends GridKeyboardDelegate + clearButtonProps: AriaButtonProps } -export function useTag(props: TagProps, state: GridState): TagAria { - let {isFocused} = props; - const { +/** + * Provides the behavior and accessibility implementation for a tag component. + * @param props - Props to be applied to the tag. + * @param state - State for the tag group, as returned by `useTagGroupState`. + */ +export function useTag(props: TagProps, state: TagGroupState): TagAria { + let { + isFocused, allowsRemoving, - onRemove, item, - tagRef, tagRowRef } = props; - const stringFormatter = useLocalizedStringFormatter(intlMessages); - const removeString = stringFormatter.format('remove'); - const labelId = useId(); - const buttonId = useId(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let removeString = stringFormatter.format('remove'); + let labelId = useId(); + let buttonId = useId(); - let {rowProps} = useGridRow({ + let {rowProps, gridCellProps} = useGridListItem({ node: item }, state, tagRowRef); - // Don't want the row to be focusable or accessible via keyboard - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {tabIndex, ...otherRowProps} = rowProps; - let {gridCellProps} = useGridCell({ - node: [...item.childNodes][0], - focusMode: 'cell' - }, state, tagRef); + // We want the group to handle keyboard navigation between tags. + delete rowProps.onKeyDownCapture; + + let onRemove = chain(props.onRemove, state.onRemove); - function onKeyDown(e: KeyboardEvent) { + let onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace' || e.key === ' ') { - onRemove(item.childNodes[0].key); + onRemove(item.key); e.preventDefault(); } - } - const pressProps = { - onPress: () => onRemove?.(item.childNodes[0].key) }; - isFocused = isFocused || state.selectionManager.focusedKey === item.childNodes[0].key; + isFocused = isFocused || state.selectionManager.focusedKey === item.key; let domProps = filterDOMProps(props); return { - clearButtonProps: mergeProps(pressProps, { + clearButtonProps: { 'aria-label': removeString, 'aria-labelledby': `${buttonId} ${labelId}`, - id: buttonId - }), + id: buttonId, + onPress: () => allowsRemoving && onRemove ? onRemove(item.key) : null + }, labelProps: { id: labelId }, - tagRowProps: otherRowProps, + tagRowProps: { + ...rowProps, + tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1, + onKeyDown: allowsRemoving ? onKeyDown : null + }, tagProps: mergeProps(domProps, gridCellProps, { 'aria-errormessage': props['aria-errormessage'], - 'aria-label': props['aria-label'], - onKeyDown: allowsRemoving ? onKeyDown : null, - tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1 + 'aria-label': props['aria-label'] }) }; } diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index c6ca99cefe0..8c94126cc72 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -10,29 +10,43 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, DOMProps} from '@react-types/shared'; +import {AriaTagGroupProps} from '@react-types/tag'; +import {DOMAttributes} from '@react-types/shared'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; -import {ReactNode, useState} from 'react'; +import {RefObject, useState} from 'react'; +import type {TagGroupState} from '@react-stately/tag'; +import {TagKeyboardDelegate} from './TagKeyboardDelegate'; import {useFocusWithin} from '@react-aria/interactions'; - -export interface AriaTagGroupProps extends DOMProps { - children: ReactNode, - isReadOnly?: boolean, // removes close button - validationState?: 'valid' | 'invalid' -} +import {useGridList} from '@react-aria/gridlist'; +import {useLocale} from '@react-aria/i18n'; export interface TagGroupAria { tagGroupProps: DOMAttributes } -export function useTagGroup(props: AriaTagGroupProps): TagGroupAria { +/** + * Provides the behavior and accessibility implementation for a tag group component. + * Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. + * @param props - Props to be applied to the tag group. + * @param state - State for the tag group, as returned by `useTagGroupState`. + * @param ref - A ref to a DOM element for the tag group. + */ +export function useTagGroup(props: AriaTagGroupProps, state: TagGroupState, ref: RefObject): TagGroupAria { + let {direction} = useLocale(); + let keyboardDelegate = new TagKeyboardDelegate(state.collection, direction); + let {gridProps} = useGridList({...props, keyboardDelegate}, state, ref); + + // Don't want the grid to be focusable or accessible via keyboard + delete gridProps.role; + delete gridProps.tabIndex; + let [isFocusWithin, setFocusWithin] = useState(false); let {focusWithinProps} = useFocusWithin({ onFocusWithinChange: setFocusWithin }); let domProps = filterDOMProps(props); return { - tagGroupProps: mergeProps(domProps, { + tagGroupProps: mergeProps(gridProps, domProps, { 'aria-atomic': false, 'aria-relevant': 'additions', 'aria-live': isFocusWithin ? 'polite' : 'off', diff --git a/packages/@react-spectrum/tag/package.json b/packages/@react-spectrum/tag/package.json index d37d97d5a13..03a1ff08d9a 100644 --- a/packages/@react-spectrum/tag/package.json +++ b/packages/@react-spectrum/tag/package.json @@ -32,8 +32,6 @@ }, "dependencies": { "@react-aria/focus": "^3.10.1", - "@react-aria/grid": "^3.5.2", - "@react-aria/i18n": "^3.6.3", "@react-aria/interactions": "^3.13.1", "@react-aria/tag": "3.0.0-beta.1", "@react-aria/utils": "^3.14.2", @@ -41,11 +39,9 @@ "@react-spectrum/text": "^3.3.4", "@react-spectrum/utils": "^3.8.1", "@react-stately/collections": "^3.5.1", - "@react-stately/grid": "^3.4.2", - "@react-stately/list": "^3.6.1", + "@react-stately/tag": "3.0.0-alpha.1", "@react-types/shared": "^3.16.0", "@react-types/tag": "3.0.0-beta.1", - "@spectrum-icons/workflow": "^4.0.6", "@swc/helpers": "^0.4.14" }, "devDependencies": { diff --git a/packages/@react-spectrum/tag/src/Tag.tsx b/packages/@react-spectrum/tag/src/Tag.tsx index 9f5f880cc2b..e8c8579cec3 100644 --- a/packages/@react-spectrum/tag/src/Tag.tsx +++ b/packages/@react-spectrum/tag/src/Tag.tsx @@ -10,17 +10,22 @@ * governing permissions and limitations under the License. */ -import {classNames, SlotProvider, useSlotProps, useStyleProps} from '@react-spectrum/utils'; +import {classNames, ClearSlots, SlotProvider, useStyleProps} from '@react-spectrum/utils'; import {ClearButton} from '@react-spectrum/button'; import {mergeProps} from '@react-aria/utils'; import React, {useRef} from 'react'; -import {SpectrumTagProps} from '@react-types/tag'; import styles from '@adobe/spectrum-css-temp/components/tags/vars.css'; +import type {TagGroupState} from '@react-stately/tag'; +import {TagProps} from '@react-types/tag'; import {Text} from '@react-spectrum/text'; import {useFocusRing} from '@react-aria/focus'; import {useHover} from '@react-aria/interactions'; import {useTag} from '@react-aria/tag'; +export interface SpectrumTagProps extends TagProps { + state: TagGroupState +} + export function Tag(props: SpectrumTagProps) { const { children, @@ -35,7 +40,6 @@ export function Tag(props: SpectrumTagProps) { let {styleProps} = useStyleProps(otherProps); let {hoverProps, isHovered} = useHover({}); let {isFocused, isFocusVisible, focusProps} = useFocusRing({within: true}); - let tagRef = useRef(); let tagRowRef = useRef(); let {clearButtonProps, labelProps, tagProps, tagRowProps} = useTag({ ...props, @@ -43,35 +47,36 @@ export function Tag(props: SpectrumTagProps) { allowsRemoving, item, onRemove, - tagRef, tagRowRef }, state); return (
-
+ ref={tagRowRef}> +
- {typeof children === 'string' ? {children} : children} - {allowsRemoving && } + + {allowsRemoving && } +
@@ -79,14 +84,11 @@ export function Tag(props: SpectrumTagProps) { } function TagRemoveButton(props) { - props = useSlotProps(props, 'tagRemoveButton'); let {styleProps} = useStyleProps(props); - let clearBtnRef = useRef(); return ( + {...styleProps}> diff --git a/packages/@react-spectrum/tag/src/TagGroup.tsx b/packages/@react-spectrum/tag/src/TagGroup.tsx index 29b6df9ee21..d3f1fe0834b 100644 --- a/packages/@react-spectrum/tag/src/TagGroup.tsx +++ b/packages/@react-spectrum/tag/src/TagGroup.tsx @@ -12,18 +12,14 @@ import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; import {DOMRef} from '@react-types/shared'; -import {GridCollection, useGridState} from '@react-stately/grid'; import {mergeProps} from '@react-aria/utils'; -import React, {ReactElement, useMemo} from 'react'; +import React, {ReactElement} from 'react'; import {SpectrumTagGroupProps} from '@react-types/tag'; import styles from '@adobe/spectrum-css-temp/components/tags/vars.css'; import {Tag} from './Tag'; -import {TagKeyboardDelegate, useTagGroup} from '@react-aria/tag'; -import {useGrid} from '@react-aria/grid'; -import {useListState} from '@react-stately/list'; -import {useLocale} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; - +import {useTagGroup} from '@react-aria/tag'; +import {useTagGroupState} from '@react-stately/tag'; function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef) { props = useProviderProps(props); @@ -34,48 +30,11 @@ function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef } = props; let domRef = useDOMRef(ref); let {styleProps} = useStyleProps(otherProps); - let {direction} = useLocale(); - let listState = useListState(props); - let gridCollection = useMemo(() => new GridCollection({ - columnCount: 1, // unused, but required for grid collections - items: [...listState.collection].map(item => { - let childNodes = [{ - ...item, - index: 0, - type: 'cell' - }]; - - return { - type: 'item', - childNodes - }; - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [listState.collection, allowsRemoving]); - let state = useGridState({ - ...props, - collection: gridCollection, - focusMode: 'cell' - }); - let keyboardDelegate = new TagKeyboardDelegate({ - collection: state.collection, - disabledKeys: new Set(), - ref: domRef, - direction, - focusMode: 'cell' - }); - let {gridProps} = useGrid({ - ...props, - keyboardDelegate - }, state, domRef); - const {tagGroupProps} = useTagGroup(props); - - // Don't want the grid to be focusable or accessible via keyboard - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let {tabIndex, role, ...otherGridProps} = gridProps; + let state = useTagGroupState(props); + let {tagGroupProps} = useTagGroup(props, state, domRef); return (
(props: SpectrumTagGroupProps, ref: DOMRef } role={state.collection.size ? 'grid' : null} ref={domRef}> - {[...gridCollection].map(item => ( + {[...state.collection].map(item => ( - {item.childNodes[0].rendered} + {item.rendered} - ))} + ))}
); } diff --git a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx index 6974b152867..84df182a7b7 100644 --- a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx +++ b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx @@ -10,26 +10,24 @@ * governing permissions and limitations under the License. */ -import {action} from '@storybook/addon-actions'; import Audio from '@spectrum-icons/workflow/Audio'; -import {Icon} from '@react-spectrum/icon'; import {Item, TagGroup} from '../src'; import React, {useState} from 'react'; import {storiesOf} from '@storybook/react'; import {Text} from '@react-spectrum/text'; +let items = [{key: '1', label: 'Cool Tag 1'}, {key: '2', label: 'Cool Tag 2'}]; + storiesOf('TagGroup', module) .add( 'default', () => render({}) ) .add('icons', () => ( - + {item => ( - - + )} @@ -38,30 +36,29 @@ storiesOf('TagGroup', module) .add( 'onRemove', () => { - const [items, setItems] = useState([ - {key: 1, label: 'Cool Tag 1'}, - {key: 2, label: 'Another cool tag'}, - {key: 3, label: 'This tag'}, - {key: 4, label: 'What tag?'}, - {key: 5, label: 'This tag is cool too'}, - {key: 6, label: 'Shy tag'} + let [items, setItems] = useState([ + {id: 1, label: 'Cool Tag 1'}, + {id: 2, label: 'Another cool tag'}, + {id: 3, label: 'This tag'}, + {id: 4, label: 'What tag?'}, + {id: 5, label: 'This tag is cool too'}, + {id: 6, label: 'Shy tag'} ]); - const onRemove = (key) => { - const newItems = [...items].filter((item) => key !== item.key.toString()); - setItems(newItems); - action('onRemove')(key); + + let removeItem = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); }; - return ( onRemove(key)}> - {item => ( - {item.label} - )} - ); + return ( + + {item => {item.label}} + + ); } ) .add('wrapping', () => (
- + Cool Tag 1 Another cool tag This tag @@ -74,7 +71,7 @@ storiesOf('TagGroup', module) ) .add('label truncation', () => (
- + Cool Tag 1 with a really long label Another long cool tag label This tag @@ -83,19 +80,17 @@ storiesOf('TagGroup', module) ) ) .add( - 'using items prop', + 'dynamic items', () => ( - - {item => - {item.label} - } + + {item => {item.label}} ) ); function render(props: any = {}) { return ( - + Cool Tag 1 Cool Tag 2 Cool Tag 3 diff --git a/packages/@react-spectrum/tag/test/TagGroup.test.js b/packages/@react-spectrum/tag/test/TagGroup.test.js index 37335c931ca..d5bfdf1dcca 100644 --- a/packages/@react-spectrum/tag/test/TagGroup.test.js +++ b/packages/@react-spectrum/tag/test/TagGroup.test.js @@ -10,8 +10,9 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, render} from '@react-spectrum/test-utils'; +import {act, fireEvent, render, triggerPress, within} from '@react-spectrum/test-utils'; import {Button} from '@react-spectrum/button'; +import {chain} from '@react-aria/utils'; import {Item} from '@react-stately/collections'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; @@ -22,6 +23,7 @@ import userEvent from '@testing-library/user-event'; function pressKeyOnButton(key) { return (button) => { fireEvent.keyDown(button, {key}); + fireEvent.keyUp(button, {key}); }; } @@ -55,7 +57,7 @@ describe('TagGroup', function () { }); it('provides context for Tag component', function () { - let {container} = render( + let {getAllByRole} = render( Tag 1 Tag 2 @@ -63,25 +65,26 @@ describe('TagGroup', function () { ); - let tags = container.querySelectorAll('[role="gridcell"]'); + let tags = getAllByRole('row'); expect(tags.length).toBe(3); fireEvent.keyDown(tags[1], {key: 'Delete'}); + fireEvent.keyUp(tags[1], {key: 'Delete'}); expect(onRemoveSpy).toHaveBeenCalledTimes(1); }); it('has correct accessibility roles', () => { - let tree = render( + let {getByRole, getAllByRole} = render( Tag 1 ); - let tagGroup = tree.getByRole('grid'); + let tagGroup = getByRole('grid'); expect(tagGroup).toBeInTheDocument(); - let tags = tree.getAllByRole('row'); - let cells = tree.getAllByRole('gridcell'); + let tags = getAllByRole('row'); + let cells = getAllByRole('gridcell'); expect(tags).toHaveLength(cells.length); }); @@ -93,7 +96,7 @@ describe('TagGroup', function () { ); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); expect(tags[0]).toHaveAttribute('tabIndex', '0'); }); @@ -104,7 +107,7 @@ describe('TagGroup', function () { ${'(up/down arrows, ltr + horizontal) TagGroup'} | ${{locale: 'de-DE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowDown, index: 1}, {action: pressArrowUp, index: 0}, {action: pressArrowUp, index: 2}]} ${'(up/down arrows, rtl + horizontal) TagGroup'} | ${{locale: 'ar-AE'}} | ${[{action: () => {userEvent.tab();}, index: 0}, {action: pressArrowUp, index: 2}, {action: pressArrowDown, index: 0}, {action: pressArrowDown, index: 1}]} `('$Name shifts button focus in the correct direction on key press', function ({Name, props, orders}) { - let tree = render( + let {getAllByRole} = render( Tag 1 @@ -114,7 +117,7 @@ describe('TagGroup', function () { ); - let tags = tree.getAllByRole('gridcell'); + let tags = getAllByRole('row'); orders.forEach(({action, index}, i) => { action(document.activeElement); expect(document.activeElement).toBe(tags[index]); @@ -172,7 +175,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonBefore.focus();}); userEvent.tab(); @@ -202,7 +205,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonBefore.focus();}); expect(buttonBefore).toHaveFocus(); userEvent.tab(); @@ -225,7 +228,7 @@ describe('TagGroup', function () { let buttonBefore = getByLabelText('ButtonBefore'); let buttonAfter = getByLabelText('ButtonAfter'); - let tags = getAllByRole('gridcell'); + let tags = getAllByRole('row'); act(() => {buttonAfter.focus();}); userEvent.tab({shift: true}); expect(document.activeElement).toBe(tags[1]); @@ -244,13 +247,12 @@ describe('TagGroup', function () { ); let tagGroup = getByRole('grid'); - let tagRow = tagGroup.children[0]; - let tag = tagRow.children[0]; + let tag = tagGroup.children[0]; expect(tag).not.toHaveAttribute('icon'); expect(tag).not.toHaveAttribute('unsafe_classname'); expect(tag).toHaveAttribute('class', expect.stringContaining('test-class')); expect(tag).toHaveAttribute('class', expect.stringContaining('-item')); - expect(tag).toHaveAttribute('role', 'gridcell'); + expect(tag).toHaveAttribute('role', 'row'); expect(tag).toHaveAttribute('tabIndex', '0'); }); @@ -260,22 +262,49 @@ describe('TagGroup', function () { Tag 1 Tag 2 + Tag 3 + Tag 4 ); - let tags = getAllByRole('gridcell'); - expect(tags.length).toBe(2); + let tags = getAllByRole('row'); + expect(tags.length).toBe(4); expect(tags[0]).toHaveAttribute('tabIndex', '0'); expect(tags[1]).toHaveAttribute('tabIndex', '0'); + expect(tags[2]).toHaveAttribute('tabIndex', '0'); + expect(tags[3]).toHaveAttribute('tabIndex', '0'); - act(() => tags[0].focus()); + userEvent.tab(); expect(tags[0]).toHaveAttribute('tabIndex', '0'); expect(tags[1]).toHaveAttribute('tabIndex', '-1'); + expect(tags[2]).toHaveAttribute('tabIndex', '-1'); + expect(tags[3]).toHaveAttribute('tabIndex', '-1'); + expect(document.activeElement).toBe(tags[0]); - pressArrowRight(tags[0]); - expect(tags[0]).toHaveAttribute('tabIndex', '-1'); - expect(tags[1]).toHaveAttribute('tabIndex', '0'); + fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowRight'}); + expect(document.activeElement).toBe(tags[1]); + + fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'}); + fireEvent.keyUp(document.activeElement, {key: 'ArrowLeft'}); + expect(document.activeElement).toBe(tags[0]); + + fireEvent.keyDown(document.activeElement, {key: 'End'}); + fireEvent.keyUp(document.activeElement, {key: 'End'}); + expect(document.activeElement).toBe(tags[3]); + + fireEvent.keyDown(document.activeElement, {key: 'Home'}); + fireEvent.keyUp(document.activeElement, {key: 'Home'}); + expect(document.activeElement).toBe(tags[0]); + + fireEvent.keyDown(document.activeElement, {key: 'PageDown'}); + fireEvent.keyUp(document.activeElement, {key: 'PageDown'}); + expect(document.activeElement).toBe(tags[1]); + + fireEvent.keyDown(document.activeElement, {key: 'PageUp'}); + fireEvent.keyUp(document.activeElement, {key: 'PageUp'}); + expect(document.activeElement).toBe(tags[0]); }); it.each` @@ -296,44 +325,76 @@ describe('TagGroup', function () { let tag = getByText('Tag 1'); fireEvent.keyDown(tag, {key: props.keyPress}); + fireEvent.keyUp(tag, {key: props.keyPress}); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); + expect(onRemoveSpy).toHaveBeenCalledWith('1'); + }); + + it('should remove tag when remove button is clicked', function () { + let {getAllByRole} = render( + + + Tag 1 + Tag 2 + Tag 3 + + + ); + + let tags = getAllByRole('row'); + triggerPress(tags[0]); + expect(onRemoveSpy).not.toHaveBeenCalled(); + + let removeButton = within(tags[0]).getByRole('button'); + triggerPress(removeButton); + expect(onRemoveSpy).toHaveBeenCalledTimes(1); expect(onRemoveSpy).toHaveBeenCalledWith('1'); }); + it.each` + Name | props + ${'on `Delete` keypress'} | ${{keyPress: 'Delete'}} + ${'on `Backspace` keypress'} | ${{keyPress: 'Backspace'}} + ${'on `space` keypress'} | ${{keyPress: ' '}} + `('Can move focus after removing tag $Name', function ({Name, props}) { + + function TagGroupWithDelete(props) { + let [items, setItems] = React.useState([ + {id: 1, label: 'Cool Tag 1'}, + {id: 2, label: 'Another cool tag'}, + {id: 3, label: 'This tag'}, + {id: 4, label: 'What tag?'}, + {id: 5, label: 'This tag is cool too'}, + {id: 6, label: 'Shy tag'} + ]); + + let removeItem = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); + }; + + return ( + + + {item => {item.label}} + + + ); + } + + let {getAllByRole} = render( + + ); - // Commented out until spectrum can provide use case for these scenarios - // it.each` - // Name | Component | TagComponent | props - // ${'TagGroup'} | ${TagGroup} | ${Item} | ${{isReadOnly: true, isRemovable: true, onRemove: onRemoveSpy}} - // `('$Name is read only', ({Component, TagComponent, props}) => { - // let {getByText} = render( - // - // Tag 1 - // - // ); - // let tag = getByText('Tag 1'); - // fireEvent.keyDown(tag, {key: 'Delete', keyCode: 46}); - // expect(onRemoveSpy).not.toHaveBeenCalledWith('Tag 1', expect.anything()); - // }); - // - // it.each` - // Name | Component | TagComponent | props - // ${'Tag'} | ${TagGroup} | ${Item} | ${{validationState: 'invalid'}} - // `('$Name can be invalid', function ({Component, TagComponent, props}) { - // let {getByRole, debug} = render( - // - // Tag 1 - // - // ); - // - // debug(); - // - // let tag = getByRole('row'); - // expect(tag).toHaveAttribute('aria-invalid', 'true'); - // }); + let tags = getAllByRole('row'); + 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]); + pressArrowRight(tags[0]); + expect(document.activeElement).toBe(tags[1]); + }); }); - -// need to add test for focus after onremove diff --git a/packages/@react-stately/tag/README.md b/packages/@react-stately/tag/README.md new file mode 100644 index 00000000000..49c22e51ddb --- /dev/null +++ b/packages/@react-stately/tag/README.md @@ -0,0 +1,3 @@ +# @react-stately/tag + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@react-stately/tag/index.ts b/packages/@react-stately/tag/index.ts new file mode 100644 index 00000000000..4e9931530d8 --- /dev/null +++ b/packages/@react-stately/tag/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './src'; diff --git a/packages/@react-stately/tag/package.json b/packages/@react-stately/tag/package.json new file mode 100644 index 00000000000..ce9b085151f --- /dev/null +++ b/packages/@react-stately/tag/package.json @@ -0,0 +1,31 @@ +{ + "name": "@react-stately/tag", + "version": "3.0.0-alpha.1", + "description": "Spectrum UI components in React", + "license": "Apache-2.0", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": [ + "dist", + "src" + ], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2", + "@react-stately/list": "^3.6.0", + "@react-types/tag": "3.0.0-beta.1", + "@swc/helpers": "^0.4.14" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@react-stately/tag/src/index.ts b/packages/@react-stately/tag/src/index.ts new file mode 100644 index 00000000000..a01676123b2 --- /dev/null +++ b/packages/@react-stately/tag/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export * from './useTagGroupState'; diff --git a/packages/@react-stately/tag/src/useTagGroupState.ts b/packages/@react-stately/tag/src/useTagGroupState.ts new file mode 100644 index 00000000000..4d0663dc692 --- /dev/null +++ b/packages/@react-stately/tag/src/useTagGroupState.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Key} from 'react'; +import {ListState, useListState} from '@react-stately/list'; +import {TagGroupProps} from '@react-types/tag'; + +export interface TagGroupState extends ListState{ + onRemove?: (key: Key) => void +} + +/** + * Provides state management for a TagGroup component. + */ +export function useTagGroupState(props: TagGroupProps): TagGroupState { + let state = useListState(props); + + let onRemove = (key) => { + // If a tag is removed, restore focus to the tag after, or tag before if no tag after. + let restoreKey = state.collection.getKeyAfter(key) || state.collection.getKeyBefore(key); + state.selectionManager.setFocusedKey(restoreKey); + }; + + return { + onRemove, + ...state + }; +} diff --git a/packages/@react-types/list/package.json b/packages/@react-types/list/package.json index 7bec3c04578..cdc6534b883 100644 --- a/packages/@react-types/list/package.json +++ b/packages/@react-types/list/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@react-aria/gridlist": "^3.1.2", - "@react-spectrum/list": "^3.2.2" + "@react-spectrum/list": "^3.2.2", + "@react-stately/list": "^3.6.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0" diff --git a/packages/@react-types/tag/package.json b/packages/@react-types/tag/package.json index b56e4a6907c..c2ccd24b075 100644 --- a/packages/@react-types/tag/package.json +++ b/packages/@react-types/tag/package.json @@ -9,7 +9,6 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { - "@react-stately/grid": "^3.4.2", "@react-types/shared": "^3.16.0" }, "peerDependencies": { diff --git a/packages/@react-types/tag/src/index.d.ts b/packages/@react-types/tag/src/index.d.ts index 8c6aef4ac68..abf451cf028 100644 --- a/packages/@react-types/tag/src/index.d.ts +++ b/packages/@react-types/tag/src/index.d.ts @@ -11,7 +11,6 @@ */ import {AriaLabelingProps, CollectionBase, DOMProps, ItemProps, Node, StyleProps} from '@react-types/shared'; -import {GridState} from '@react-stately/grid'; import {Key, RefObject} from 'react'; export interface TagGroupProps extends Omit, 'disabledKeys'> { @@ -21,17 +20,14 @@ export interface TagGroupProps extends Omit, 'disabledKeys' onRemove?: (key: Key) => void } -export interface SpectrumTagGroupProps extends TagGroupProps, DOMProps, StyleProps, AriaLabelingProps {} +export interface AriaTagGroupProps extends TagGroupProps, DOMProps, AriaLabelingProps {} + +export interface SpectrumTagGroupProps extends AriaTagGroupProps, StyleProps {} export interface TagProps extends ItemProps { isFocused: boolean, allowsRemoving?: boolean, item: Node, onRemove?: (key: Key) => void, - tagRef: RefObject, tagRowRef: RefObject } - -interface SpectrumTagProps extends TagProps { - state: GridState -}