Skip to content

Commit 9aa0c7e

Browse files
authored
TagGroup: Add useTagGroupState and fix focus issues (#3798)
* add useTagGroupState * update TagGroup to use useTagGroupState * update useTagGroup * cleanup useTag * cleanup stories * add comment * remove private from package.json * lint * fix types * lint * fix types * update removal string * switch to focus row instead of cell * fix types * add test for focus after deleting * set focusMode: 'cell' * cleanup useTag * lint * update translation string punctuation * fix type * refactor to use useGridList * update tests * improve types * lint * update comments * add test for clicking remove button * update tests to check that onRemove is called once * cleanup onPress and clearButtonProps type * fix text overflow and cleanup styles * update dependencies * cleanup Tag * update keyboard focus tests * fix types and imports * style fix * update tests * only restore focus if onRemove called * cleanup chain * formatting * lint * fix types * lint * lint * lint * formatting * remove unnecessary export
1 parent 86b9d3d commit 9aa0c7e

File tree

20 files changed

+395
-294
lines changed

20 files changed

+395
-294
lines changed

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

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ governing permissions and limitations under the License.
1313
@import '../commons/index.css';
1414

1515
.spectrum-Tags {
16-
display: inline-flex;
16+
display: flex;
1717
flex-wrap: wrap;
1818

1919
margin: 0;
@@ -27,7 +27,7 @@ governing permissions and limitations under the License.
2727
--spectrum-focus-ring-border-size: var(--spectrum-tag-border-size);
2828

2929
display: grid;
30-
grid-template-columns: 1fr auto;
30+
grid-template-columns: auto 1fr auto;
3131
grid-template-areas: "icon content action";
3232
align-items: center;
3333
box-sizing: border-box;
@@ -58,28 +58,36 @@ governing permissions and limitations under the License.
5858
height: calc(var(--spectrum-tag-height) - (2 * var(--spectrum-tag-border-size)));
5959
width: var(--spectrum-global-dimension-size-300);
6060
}
61-
}
6261

63-
.spectrum-Tag-icon {
64-
grid-area: icon;
65-
margin-inline-end: var(--spectrum-global-dimension-size-100);
66-
}
62+
.spectrum-Tag-cell {
63+
overflow: hidden;
64+
text-overflow: ellipsis;
65+
display: flex;
66+
align-items: center;
67+
}
6768

68-
.spectrum-Tag-content {
69-
grid-area: content;
70-
block-size: 100%;
71-
line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2));
72-
margin-inline-end: var(--spectrum-tag-padding-x);
73-
flex: 1 1 auto;
74-
font-size: var(--spectrum-tag-text-size);
75-
cursor: default;
76-
overflow: hidden;
77-
white-space: nowrap;
78-
text-overflow: ellipsis;
79-
outline: none;
80-
}
69+
.spectrum-Tag-icon {
70+
grid-area: icon;
71+
margin-inline-end: var(--spectrum-global-dimension-size-100);
72+
}
73+
74+
.spectrum-Tag-content {
75+
grid-area: content;
76+
line-height: calc(var(--spectrum-tag-height) - calc(var(--spectrum-tag-border-size) * 2));
77+
margin-inline-end: var(--spectrum-tag-padding-x);
78+
flex: 1 1 auto;
79+
font-size: var(--spectrum-tag-text-size);
80+
cursor: default;
81+
overflow: hidden;
82+
white-space: nowrap;
83+
text-overflow: ellipsis;
84+
outline: none;
85+
}
8186

82-
.tags-removable {
83-
margin-inline-end: 0;
87+
&.is-removable {
88+
.spectrum-Tag-content {
89+
margin-inline-end: 0;
90+
}
91+
}
8492
}
8593

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"remove": "Remove"
2+
"remove": "Press Space or Delete to remove tag."
33
}

packages/@react-aria/tag/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@
1717
"url": "https://github.com/adobe/react-spectrum"
1818
},
1919
"dependencies": {
20-
"@react-aria/grid": "^3.5.2",
20+
"@react-aria/gridlist": "^3.1.1",
2121
"@react-aria/i18n": "^3.6.3",
2222
"@react-aria/interactions": "^3.13.1",
2323
"@react-aria/utils": "^3.14.2",
24-
"@react-stately/grid": "^3.4.2",
25-
"@react-types/grid": "^3.1.5",
24+
"@react-stately/tag": "3.0.0-alpha.1",
25+
"@react-types/button": "^3.7.0",
2626
"@react-types/shared": "^3.16.0",
2727
"@react-types/tag": "3.0.0-beta.1",
2828
"@swc/helpers": "^0.4.14"

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

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,26 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {GridCollection} from '@react-types/grid';
14-
import {GridKeyboardDelegate} from '@react-aria/grid';
13+
import {Collection, Direction, KeyboardDelegate} from '@react-types/shared';
1514
import {Key} from 'react';
1615

17-
export class TagKeyboardDelegate<T> extends GridKeyboardDelegate<T, GridCollection<T>> {
18-
getFirstKey() {
19-
let key = this.collection.getFirstKey();
20-
let item = this.collection.getItem(key);
16+
export class TagKeyboardDelegate<T> implements KeyboardDelegate {
17+
private collection: Collection<T>;
18+
private direction: Direction;
2119

22-
return [...item.childNodes][0].key;
20+
constructor(collection: Collection<T>, direction: Direction) {
21+
this.collection = collection;
22+
this.direction = direction;
2323
}
2424

25-
getLastKey() {
26-
let key = this.collection.getLastKey();
27-
let item = this.collection.getItem(key);
28-
29-
return [...item.childNodes][0].key;
25+
getFirstKey() {
26+
return this.collection.getFirstKey();
3027
}
3128

29+
getLastKey() {
30+
return this.collection.getLastKey();
31+
}
32+
3233
getKeyRightOf(key: Key) {
3334
return this.direction === 'rtl' ? this.getKeyAbove(key) : this.getKeyBelow(key);
3435
}
@@ -43,27 +44,12 @@ export class TagKeyboardDelegate<T> extends GridKeyboardDelegate<T, GridCollecti
4344
return;
4445
}
4546

46-
// If focus was on a cell, start searching from the parent row
47-
if (this.isCell(startItem)) {
48-
key = startItem.parentKey;
49-
}
50-
5147
// Find the next item
52-
key = this.findNextKey(key);
48+
key = this.collection.getKeyAfter(key);
5349
if (key != null) {
54-
// If focus was on a cell, focus the cell with the same index in the next row.
55-
if (this.isCell(startItem)) {
56-
let item = this.collection.getItem(key);
57-
58-
return [...item.childNodes][startItem.index].key;
59-
}
60-
61-
// Otherwise, focus the next row
62-
if (this.focusMode === 'row') {
63-
return key;
64-
}
50+
return key;
6551
} else {
66-
return this.getFirstKey();
52+
return this.collection.getFirstKey();
6753
}
6854
}
6955

@@ -73,26 +59,12 @@ export class TagKeyboardDelegate<T> extends GridKeyboardDelegate<T, GridCollecti
7359
return;
7460
}
7561

76-
// If focus is on a cell, start searching from the parent row
77-
if (this.isCell(startItem)) {
78-
key = startItem.parentKey;
79-
}
80-
8162
// Find the previous item
82-
key = this.findPreviousKey(key);
63+
key = this.collection.getKeyBefore(key);
8364
if (key != null) {
84-
// If focus was on a cell, focus the cell with the same index in the previous row.
85-
if (this.isCell(startItem)) {
86-
let item = this.collection.getItem(key);
87-
return [...item.childNodes][startItem.index].key;
88-
}
89-
90-
// Otherwise, focus the previous row
91-
if (this.focusMode === 'row') {
92-
return key;
93-
}
65+
return key;
9466
} else {
95-
return this.getLastKey();
67+
return this.collection.getLastKey();
9668
}
9769
}
9870

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ export {useTag} from './useTag';
1515
export {useTagGroup} from './useTagGroup';
1616

1717
export type {TagProps} from '@react-types/tag';
18-
export type {AriaTagGroupProps, TagGroupAria} from './useTagGroup';
18+
export type {TagGroupAria} from './useTagGroup';
1919
export type {TagAria} from './useTag';

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

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -10,77 +10,78 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {ButtonHTMLAttributes, KeyboardEvent} from 'react';
13+
import {AriaButtonProps} from '@react-types/button';
14+
import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils';
1415
import {DOMAttributes} from '@react-types/shared';
15-
import {filterDOMProps, mergeProps, useId} from '@react-aria/utils';
16-
import {GridState} from '@react-stately/grid';
1716
// @ts-ignore
1817
import intlMessages from '../intl/*.json';
18+
import {KeyboardEvent} from 'react';
19+
import type {TagGroupState} from '@react-stately/tag';
1920
import {TagProps} from '@react-types/tag';
20-
import {useGridCell, useGridRow} from '@react-aria/grid';
21+
import {useGridListItem} from '@react-aria/gridlist';
2122
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2223

2324

2425
export interface TagAria {
2526
labelProps: DOMAttributes,
2627
tagProps: DOMAttributes,
2728
tagRowProps: DOMAttributes,
28-
clearButtonProps: ButtonHTMLAttributes<HTMLButtonElement>
29+
clearButtonProps: AriaButtonProps
2930
}
3031

31-
export function useTag(props: TagProps<any>, state: GridState<any, any>): TagAria {
32-
let {isFocused} = props;
33-
const {
32+
/**
33+
* Provides the behavior and accessibility implementation for a tag component.
34+
* @param props - Props to be applied to the tag.
35+
* @param state - State for the tag group, as returned by `useTagGroupState`.
36+
*/
37+
export function useTag<T>(props: TagProps<T>, state: TagGroupState<T>): TagAria {
38+
let {
39+
isFocused,
3440
allowsRemoving,
35-
onRemove,
3641
item,
37-
tagRef,
3842
tagRowRef
3943
} = props;
40-
const stringFormatter = useLocalizedStringFormatter(intlMessages);
41-
const removeString = stringFormatter.format('remove');
42-
const labelId = useId();
43-
const buttonId = useId();
44+
let stringFormatter = useLocalizedStringFormatter(intlMessages);
45+
let removeString = stringFormatter.format('remove');
46+
let labelId = useId();
47+
let buttonId = useId();
4448

45-
let {rowProps} = useGridRow({
49+
let {rowProps, gridCellProps} = useGridListItem({
4650
node: item
4751
}, state, tagRowRef);
48-
// Don't want the row to be focusable or accessible via keyboard
49-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
50-
let {tabIndex, ...otherRowProps} = rowProps;
5152

52-
let {gridCellProps} = useGridCell({
53-
node: [...item.childNodes][0],
54-
focusMode: 'cell'
55-
}, state, tagRef);
53+
// We want the group to handle keyboard navigation between tags.
54+
delete rowProps.onKeyDownCapture;
55+
56+
let onRemove = chain(props.onRemove, state.onRemove);
5657

57-
function onKeyDown(e: KeyboardEvent) {
58+
let onKeyDown = (e: KeyboardEvent) => {
5859
if (e.key === 'Delete' || e.key === 'Backspace' || e.key === ' ') {
59-
onRemove(item.childNodes[0].key);
60+
onRemove(item.key);
6061
e.preventDefault();
6162
}
62-
}
63-
const pressProps = {
64-
onPress: () => onRemove?.(item.childNodes[0].key)
6563
};
6664

67-
isFocused = isFocused || state.selectionManager.focusedKey === item.childNodes[0].key;
65+
isFocused = isFocused || state.selectionManager.focusedKey === item.key;
6866
let domProps = filterDOMProps(props);
6967
return {
70-
clearButtonProps: mergeProps(pressProps, {
68+
clearButtonProps: {
7169
'aria-label': removeString,
7270
'aria-labelledby': `${buttonId} ${labelId}`,
73-
id: buttonId
74-
}),
71+
id: buttonId,
72+
onPress: () => allowsRemoving && onRemove ? onRemove(item.key) : null
73+
},
7574
labelProps: {
7675
id: labelId
7776
},
78-
tagRowProps: otherRowProps,
77+
tagRowProps: {
78+
...rowProps,
79+
tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1,
80+
onKeyDown: allowsRemoving ? onKeyDown : null
81+
},
7982
tagProps: mergeProps(domProps, gridCellProps, {
8083
'aria-errormessage': props['aria-errormessage'],
81-
'aria-label': props['aria-label'],
82-
onKeyDown: allowsRemoving ? onKeyDown : null,
83-
tabIndex: (isFocused || state.selectionManager.focusedKey == null) ? 0 : -1
84+
'aria-label': props['aria-label']
8485
})
8586
};
8687
}

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

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,43 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {DOMAttributes, DOMProps} from '@react-types/shared';
13+
import {AriaTagGroupProps} from '@react-types/tag';
14+
import {DOMAttributes} from '@react-types/shared';
1415
import {filterDOMProps, mergeProps} from '@react-aria/utils';
15-
import {ReactNode, useState} from 'react';
16+
import {RefObject, useState} from 'react';
17+
import type {TagGroupState} from '@react-stately/tag';
18+
import {TagKeyboardDelegate} from './TagKeyboardDelegate';
1619
import {useFocusWithin} from '@react-aria/interactions';
17-
18-
export interface AriaTagGroupProps extends DOMProps {
19-
children: ReactNode,
20-
isReadOnly?: boolean, // removes close button
21-
validationState?: 'valid' | 'invalid'
22-
}
20+
import {useGridList} from '@react-aria/gridlist';
21+
import {useLocale} from '@react-aria/i18n';
2322

2423
export interface TagGroupAria {
2524
tagGroupProps: DOMAttributes
2625
}
2726

28-
export function useTagGroup(props: AriaTagGroupProps): TagGroupAria {
27+
/**
28+
* Provides the behavior and accessibility implementation for a tag group component.
29+
* Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request.
30+
* @param props - Props to be applied to the tag group.
31+
* @param state - State for the tag group, as returned by `useTagGroupState`.
32+
* @param ref - A ref to a DOM element for the tag group.
33+
*/
34+
export function useTagGroup<T>(props: AriaTagGroupProps<T>, state: TagGroupState<T>, ref: RefObject<HTMLElement>): TagGroupAria {
35+
let {direction} = useLocale();
36+
let keyboardDelegate = new TagKeyboardDelegate(state.collection, direction);
37+
let {gridProps} = useGridList({...props, keyboardDelegate}, state, ref);
38+
39+
// Don't want the grid to be focusable or accessible via keyboard
40+
delete gridProps.role;
41+
delete gridProps.tabIndex;
42+
2943
let [isFocusWithin, setFocusWithin] = useState(false);
3044
let {focusWithinProps} = useFocusWithin({
3145
onFocusWithinChange: setFocusWithin
3246
});
3347
let domProps = filterDOMProps(props);
3448
return {
35-
tagGroupProps: mergeProps(domProps, {
49+
tagGroupProps: mergeProps(gridProps, domProps, {
3650
'aria-atomic': false,
3751
'aria-relevant': 'additions',
3852
'aria-live': isFocusWithin ? 'polite' : 'off',

packages/@react-spectrum/tag/package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,16 @@
3232
},
3333
"dependencies": {
3434
"@react-aria/focus": "^3.10.1",
35-
"@react-aria/grid": "^3.5.2",
36-
"@react-aria/i18n": "^3.6.3",
3735
"@react-aria/interactions": "^3.13.1",
3836
"@react-aria/tag": "3.0.0-beta.1",
3937
"@react-aria/utils": "^3.14.2",
4038
"@react-spectrum/button": "^3.11.2",
4139
"@react-spectrum/text": "^3.3.4",
4240
"@react-spectrum/utils": "^3.8.1",
4341
"@react-stately/collections": "^3.5.1",
44-
"@react-stately/grid": "^3.4.2",
45-
"@react-stately/list": "^3.6.1",
42+
"@react-stately/tag": "3.0.0-alpha.1",
4643
"@react-types/shared": "^3.16.0",
4744
"@react-types/tag": "3.0.0-beta.1",
48-
"@spectrum-icons/workflow": "^4.0.6",
4945
"@swc/helpers": "^0.4.14"
5046
},
5147
"devDependencies": {

0 commit comments

Comments
 (0)