@react-spectrum/tag 3.0.0-nightly.3180 → 3.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/TagGroup.tsx CHANGED
@@ -10,113 +10,223 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils';
14
- import {DOMRef} from '@react-types/shared';
15
- import {GridCollection, useGridState} from '@react-stately/grid';
16
- import {mergeProps} from '@react-aria/utils';
17
- import React, {ReactElement, useMemo} from 'react';
18
- import {SpectrumTagGroupProps} from '@react-types/tag';
13
+ import {ActionButton} from '@react-spectrum/button';
14
+ import {AriaTagGroupProps, TagKeyboardDelegate, useTagGroup} from '@react-aria/tag';
15
+ import {classNames, useDOMRef} from '@react-spectrum/utils';
16
+ import {DOMRef, SpectrumHelpTextProps, SpectrumLabelableProps, StyleProps} from '@react-types/shared';
17
+ import {Field} from '@react-spectrum/label';
18
+ import {FocusScope} from '@react-aria/focus';
19
+ // @ts-ignore
20
+ import intlMessages from '../intl/*.json';
21
+ import {ListCollection} from '@react-stately/list';
22
+ import {Provider, useProviderProps} from '@react-spectrum/provider';
23
+ import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
19
24
  import styles from '@adobe/spectrum-css-temp/components/tags/vars.css';
20
25
  import {Tag} from './Tag';
21
- import {TagKeyboardDelegate, useTagGroup} from '@react-aria/tag';
22
- import {useGrid} from '@react-aria/grid';
23
- import {useListState} from '@react-stately/list';
24
- import {useLocale} from '@react-aria/i18n';
25
- import {useProviderProps} from '@react-spectrum/provider';
26
+ import {useFormProps} from '@react-spectrum/form';
27
+ import {useId, useLayoutEffect, useResizeObserver, useValueEffect} from '@react-aria/utils';
28
+ import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
29
+ import {useTagGroupState} from '@react-stately/tag';
26
30
 
31
+ export interface SpectrumTagGroupProps<T> extends AriaTagGroupProps<T>, StyleProps, SpectrumLabelableProps, Omit<SpectrumHelpTextProps, 'showErrorIcon'> {
32
+ /** The label to display on the action button. */
33
+ actionLabel?: string,
34
+ /** Handler that is called when the action button is pressed. */
35
+ onAction?: () => void
36
+ }
27
37
 
28
38
  function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef<HTMLDivElement>) {
29
39
  props = useProviderProps(props);
40
+ props = useFormProps(props);
30
41
  let {
31
- isDisabled,
32
- isRemovable,
42
+ allowsRemoving,
33
43
  onRemove,
34
- ...otherProps
44
+ maxRows,
45
+ children,
46
+ actionLabel,
47
+ onAction,
48
+ labelPosition
35
49
  } = props;
36
50
  let domRef = useDOMRef(ref);
37
- let {styleProps} = useStyleProps(otherProps);
51
+ let containerRef = useRef(null);
52
+ let tagsRef = useRef(null);
38
53
  let {direction} = useLocale();
39
- let listState = useListState(props);
40
- let gridCollection = useMemo(() => new GridCollection({
41
- columnCount: isRemovable ? 2 : 1,
42
- items: [...listState.collection].map(item => {
43
- let childNodes = [{
44
- ...item,
45
- index: 0,
46
- type: 'cell'
47
- }];
48
-
49
- // add column of clear buttons if removable
50
- if (isRemovable) {
51
- childNodes.push({
52
- key: `remove-${item.key}`,
53
- type: 'cell',
54
- index: 1,
55
- value: null,
56
- level: 0,
57
- rendered: null,
58
- textValue: item.textValue, // TODO localize?
59
- hasChildNodes: false,
60
- childNodes: []
61
- });
62
- }
63
-
64
- return {
65
- type: 'item',
66
- childNodes
54
+ let stringFormatter = useLocalizedStringFormatter(intlMessages);
55
+ let [isCollapsed, setIsCollapsed] = useState(maxRows != null);
56
+ let state = useTagGroupState(props);
57
+ let [tagState, setTagState] = useValueEffect({visibleTagCount: state.collection.size, showCollapseButton: false, maxHeight: undefined});
58
+ let keyboardDelegate = useMemo(() => (
59
+ isCollapsed
60
+ ? new TagKeyboardDelegate(new ListCollection([...state.collection].slice(0, tagState.visibleTagCount)), direction)
61
+ : new TagKeyboardDelegate(new ListCollection([...state.collection]), direction)
62
+ ), [direction, isCollapsed, state.collection, tagState.visibleTagCount]) as TagKeyboardDelegate<T>;
63
+ // Remove onAction from props so it doesn't make it into useGridList.
64
+ delete props.onAction;
65
+ let {gridProps, labelProps, descriptionProps, errorMessageProps} = useTagGroup({...props, keyboardDelegate}, state, tagsRef);
66
+ let actionsId = useId();
67
+
68
+ let updateVisibleTagCount = useCallback(() => {
69
+ if (maxRows > 0) {
70
+ let computeVisibleTagCount = () => {
71
+ // Refs can be null at runtime.
72
+ let currContainerRef: HTMLDivElement | null = containerRef.current;
73
+ let currTagsRef: HTMLDivElement | null = tagsRef.current;
74
+ if (!currContainerRef || !currTagsRef) {
75
+ return;
76
+ }
77
+
78
+ let tags = [...currTagsRef.children];
79
+ let buttons = [...currContainerRef.parentElement.querySelectorAll('button')];
80
+ let currY = -Infinity;
81
+ let rowCount = 0;
82
+ let index = 0;
83
+ let tagWidths: number[] = [];
84
+ // Count rows and show tags until we hit the maxRows.
85
+ for (let tag of tags) {
86
+ let {width, y} = tag.getBoundingClientRect();
87
+
88
+ if (y !== currY) {
89
+ currY = y;
90
+ rowCount++;
91
+ }
92
+
93
+ if (rowCount > maxRows) {
94
+ break;
95
+ }
96
+ tagWidths.push(width);
97
+ index++;
98
+ }
99
+
100
+ // Remove tags until there is space for the collapse button and action button (if present) on the last row.
101
+ let buttonsWidth = buttons.reduce((acc, curr) => acc += curr.getBoundingClientRect().width, 0);
102
+ buttonsWidth += parseInt(window.getComputedStyle(buttons[buttons.length - 1]).marginRight, 10) * 2;
103
+ let end = direction === 'ltr' ? 'right' : 'left';
104
+ let containerEnd = currContainerRef.parentElement.getBoundingClientRect()[end];
105
+ let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end];
106
+ lastTagEnd += parseInt(window.getComputedStyle(tags[index - 1]).marginRight, 10);
107
+ let availableWidth = containerEnd - lastTagEnd;
108
+
109
+ while (availableWidth < buttonsWidth && index < state.collection.size && index > 0) {
110
+ availableWidth += tagWidths.pop();
111
+ index--;
112
+ }
113
+ let tagStyle = window.getComputedStyle(tags[0]);
114
+ let maxHeight = (parseInt(tagStyle.height, 10) + parseInt(tagStyle.marginTop, 10) * 2) * maxRows;
115
+ return {
116
+ visibleTagCount: index,
117
+ showCollapseButton: index < state.collection.size,
118
+ maxHeight
119
+ };
67
120
  };
68
- })
69
- }), [listState.collection, isRemovable]);
70
- let state = useGridState({
71
- ...props,
72
- collection: gridCollection,
73
- focusMode: 'cell'
74
- });
75
- let keyboardDelegate = new TagKeyboardDelegate({
76
- collection: state.collection,
77
- disabledKeys: state.disabledKeys,
78
- ref: domRef,
79
- direction,
80
- focusMode: 'cell'
81
- });
82
- let {gridProps} = useGrid({
83
- ...props,
84
- keyboardDelegate
85
- }, state, domRef);
86
- const {tagGroupProps} = useTagGroup(props, listState);
87
-
88
- // Don't want the grid to be focusable or accessible via keyboard
89
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
90
- let {tabIndex, ...otherGridProps} = gridProps;
121
+
122
+ setTagState(function *() {
123
+ // Update to show all items.
124
+ yield {visibleTagCount: state.collection.size, showCollapseButton: true, maxHeight: undefined};
125
+
126
+ // Measure, and update to show the items until maxRows is reached.
127
+ yield computeVisibleTagCount();
128
+ });
129
+ }
130
+ }, [maxRows, setTagState, direction, state.collection.size]);
131
+
132
+ useResizeObserver({ref: containerRef, onResize: updateVisibleTagCount});
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ useLayoutEffect(updateVisibleTagCount, [children]);
135
+
136
+ useEffect(() => {
137
+ // Recalculate visible tags when fonts are loaded.
138
+ document.fonts?.ready.then(() => updateVisibleTagCount());
139
+ // eslint-disable-next-line react-hooks/exhaustive-deps
140
+ }, []);
141
+
142
+ let visibleTags = [...state.collection];
143
+ if (maxRows != null && isCollapsed) {
144
+ visibleTags = visibleTags.slice(0, tagState.visibleTagCount);
145
+ }
146
+
147
+ let handlePressCollapse = () => {
148
+ // Prevents button from losing focus if focusedKey got collapsed.
149
+ state.selectionManager.setFocusedKey(null);
150
+ setIsCollapsed(prevCollapsed => !prevCollapsed);
151
+ };
152
+
153
+ let showActions = tagState.showCollapseButton || (actionLabel && onAction);
154
+
91
155
  return (
92
- <div
93
- {...mergeProps(styleProps, tagGroupProps, otherGridProps)}
94
- className={
95
- classNames(
96
- styles,
97
- 'spectrum-Tags',
98
- {
99
- 'is-disabled': isDisabled
100
- },
101
- styleProps.className
102
- )
103
- }
104
- ref={domRef}>
105
- {[...gridCollection].map(item => (
106
- <Tag
107
- {...item.childNodes[0].props}
108
- key={item.key}
109
- item={item}
110
- state={state}
111
- isDisabled={isDisabled || state.disabledKeys.has(item?.childNodes[0]?.key)}
112
- isRemovable={isRemovable}
113
- onRemove={onRemove}>
114
- {item.childNodes[0].rendered}
115
- </Tag>
116
- ))}
117
- </div>
156
+ <FocusScope>
157
+ <Field
158
+ {...props}
159
+ labelProps={labelProps}
160
+ descriptionProps={descriptionProps}
161
+ errorMessageProps={errorMessageProps}
162
+ showErrorIcon
163
+ ref={domRef}
164
+ elementType="span"
165
+ UNSAFE_className={
166
+ classNames(
167
+ styles,
168
+ 'spectrum-Tags-fieldWrapper',
169
+ {
170
+ 'spectrum-Tags-fieldWrapper--positionSide': labelPosition === 'side'
171
+ }
172
+ )
173
+ }>
174
+ <div
175
+ style={maxRows != null && tagState.showCollapseButton && isCollapsed ? {maxHeight: tagState.maxHeight, overflow: 'hidden'} : undefined}
176
+ ref={containerRef}
177
+ className={classNames(styles, 'spectrum-Tags-container')}>
178
+ <div
179
+ ref={tagsRef}
180
+ {...gridProps}
181
+ className={classNames(styles, 'spectrum-Tags')}>
182
+ {visibleTags.map(item => (
183
+ <Tag
184
+ {...item.props}
185
+ key={item.key}
186
+ item={item}
187
+ state={state}
188
+ allowsRemoving={allowsRemoving}
189
+ onRemove={onRemove}>
190
+ {item.rendered}
191
+ </Tag>
192
+ ))}
193
+ </div>
194
+ {showActions &&
195
+ <Provider isDisabled={false}>
196
+ <div
197
+ role="group"
198
+ id={actionsId}
199
+ aria-label={stringFormatter.format('actions')}
200
+ aria-labelledby={`${gridProps.id} ${actionsId}`}
201
+ className={classNames(styles, 'spectrum-Tags-actions')}>
202
+ {tagState.showCollapseButton &&
203
+ <ActionButton
204
+ isQuiet
205
+ onPress={handlePressCollapse}
206
+ UNSAFE_className={classNames(styles, 'spectrum-Tags-actionButton')}>
207
+ {isCollapsed ?
208
+ stringFormatter.format('showAllButtonLabel', {tagCount: state.collection.size}) :
209
+ stringFormatter.format('hideButtonLabel')
210
+ }
211
+ </ActionButton>
212
+ }
213
+ {actionLabel && onAction &&
214
+ <ActionButton
215
+ isQuiet
216
+ onPress={() => onAction?.()}
217
+ UNSAFE_className={classNames(styles, 'spectrum-Tags-actionButton')}>
218
+ {actionLabel}
219
+ </ActionButton>
220
+ }
221
+ </div>
222
+ </Provider>
223
+ }
224
+ </div>
225
+ </Field>
226
+ </FocusScope>
118
227
  );
119
228
  }
120
229
 
230
+ /** Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. */
121
231
  const _TagGroup = React.forwardRef(TagGroup) as <T>(props: SpectrumTagGroupProps<T> & {ref?: DOMRef<HTMLDivElement>}) => ReactElement;
122
232
  export {_TagGroup as TagGroup};
package/src/index.ts CHANGED
@@ -10,6 +10,6 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- /// <reference types="css-module-types" />
14
-
15
- export * from './TagGroup';
13
+ export {TagGroup} from './TagGroup';
14
+ export {Item} from '@react-stately/collections';
15
+ export type {SpectrumTagGroupProps} from './TagGroup';