@react-spectrum/tag 3.0.0-nightly.3180 → 3.0.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,272 @@
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, useTagGroup} from '@react-aria/tag';
15
+ import {classNames, useDOMRef} from '@react-spectrum/utils';
16
+ import {DOMRef, SpectrumLabelableProps, StyleProps, Validation} from '@react-types/shared';
17
+ import {Field} from '@react-spectrum/label';
18
+ import {FocusRing, FocusScope} from '@react-aria/focus';
19
+ // @ts-ignore
20
+ import intlMessages from '../intl/*.json';
21
+ import {ListCollection, useListState} from '@react-stately/list';
22
+ import {ListKeyboardDelegate} from '@react-aria/selection';
23
+ import {Provider, useProvider, useProviderProps} from '@react-spectrum/provider';
24
+ import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
19
25
  import styles from '@adobe/spectrum-css-temp/components/tags/vars.css';
20
26
  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';
27
+ import {useFormProps} from '@react-spectrum/form';
28
+ import {useId, useLayoutEffect, useResizeObserver, useValueEffect} from '@react-aria/utils';
29
+ import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
26
30
 
31
+ const TAG_STYLES = {
32
+ medium: {
33
+ height: 24,
34
+ margin: 4
35
+ },
36
+ large: {
37
+ height: 30,
38
+ margin: 5
39
+ }
40
+ };
41
+
42
+ export interface SpectrumTagGroupProps<T> extends Omit<AriaTagGroupProps<T>, 'selectionMode' | 'disallowEmptySelection' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange' | 'selectionBehavior' | 'disabledKeys'>, StyleProps, Omit<SpectrumLabelableProps, 'isRequired' | 'necessityIndicator'>, Omit<Validation, 'isRequired'> {
43
+ /** The label to display on the action button. */
44
+ actionLabel?: string,
45
+ /** Handler that is called when the action button is pressed. */
46
+ onAction?: () => void,
47
+ /** Sets what the TagGroup should render when there are no tags to display. */
48
+ renderEmptyState?: () => JSX.Element,
49
+ /** Limit the number of rows initially shown. This will render a button that allows the user to expand to show all tags. */
50
+ maxRows?: number
51
+ }
27
52
 
28
53
  function TagGroup<T extends object>(props: SpectrumTagGroupProps<T>, ref: DOMRef<HTMLDivElement>) {
29
54
  props = useProviderProps(props);
55
+ props = useFormProps(props);
30
56
  let {
31
- isDisabled,
32
- isRemovable,
33
- onRemove,
34
- ...otherProps
57
+ maxRows,
58
+ children,
59
+ actionLabel,
60
+ onAction,
61
+ labelPosition,
62
+ renderEmptyState = () => stringFormatter.format('noTags')
35
63
  } = props;
36
64
  let domRef = useDOMRef(ref);
37
- let {styleProps} = useStyleProps(otherProps);
65
+ let containerRef = useRef(null);
66
+ let tagsRef = useRef(null);
38
67
  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
68
+ let {scale} = useProvider();
69
+ let stringFormatter = useLocalizedStringFormatter(intlMessages);
70
+ let [isCollapsed, setIsCollapsed] = useState(maxRows != null);
71
+ let state = useListState(props);
72
+ let [tagState, setTagState] = useValueEffect({visibleTagCount: state.collection.size, showCollapseButton: false});
73
+ let keyboardDelegate = useMemo(() => {
74
+ let collection = isCollapsed
75
+ ? new ListCollection([...state.collection].slice(0, tagState.visibleTagCount))
76
+ : new ListCollection([...state.collection]);
77
+ return new ListKeyboardDelegate({
78
+ collection,
79
+ ref: domRef,
80
+ direction,
81
+ orientation: 'horizontal'
82
+ });
83
+ }, [direction, isCollapsed, state.collection, tagState.visibleTagCount, domRef]) as ListKeyboardDelegate<T>;
84
+ // Remove onAction from props so it doesn't make it into useGridList.
85
+ delete props.onAction;
86
+ let {gridProps, labelProps, descriptionProps, errorMessageProps} = useTagGroup({...props, keyboardDelegate}, state, tagsRef);
87
+ let actionsId = useId();
88
+ let actionsRef = useRef(null);
89
+
90
+ let updateVisibleTagCount = useCallback(() => {
91
+ if (maxRows > 0) {
92
+ let computeVisibleTagCount = () => {
93
+ // Refs can be null at runtime.
94
+ let currContainerRef: HTMLDivElement | null = containerRef.current;
95
+ let currTagsRef: HTMLDivElement | null = tagsRef.current;
96
+ let currActionsRef: HTMLDivElement | null = actionsRef.current;
97
+ if (!currContainerRef || !currTagsRef || state.collection.size === 0) {
98
+ return {
99
+ visibleTagCount: 0,
100
+ showCollapseButton: false
101
+ };
102
+ }
103
+
104
+ // Count rows and show tags until we hit the maxRows.
105
+ let tags = [...currTagsRef.children];
106
+ let currY = -Infinity;
107
+ let rowCount = 0;
108
+ let index = 0;
109
+ let tagWidths: number[] = [];
110
+ for (let tag of tags) {
111
+ let {width, y} = tag.getBoundingClientRect();
112
+
113
+ if (y !== currY) {
114
+ currY = y;
115
+ rowCount++;
116
+ }
117
+
118
+ if (rowCount > maxRows) {
119
+ break;
120
+ }
121
+ tagWidths.push(width);
122
+ index++;
123
+ }
124
+
125
+ // Remove tags until there is space for the collapse button and action button (if present) on the last row.
126
+ let buttons = [...currActionsRef.children];
127
+ if (buttons.length > 0 && rowCount >= maxRows) {
128
+ let buttonsWidth = buttons.reduce((acc, curr) => acc += curr.getBoundingClientRect().width, 0);
129
+ buttonsWidth += TAG_STYLES[scale].margin * 2 * buttons.length;
130
+ let end = direction === 'ltr' ? 'right' : 'left';
131
+ let containerEnd = currContainerRef.parentElement.getBoundingClientRect()[end];
132
+ let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end];
133
+ lastTagEnd += TAG_STYLES[scale].margin;
134
+ let availableWidth = containerEnd - lastTagEnd;
135
+
136
+ while (availableWidth < buttonsWidth && index > 0) {
137
+ availableWidth += tagWidths.pop();
138
+ index--;
139
+ }
140
+ }
141
+
142
+ return {
143
+ visibleTagCount: Math.max(index, 1),
144
+ showCollapseButton: index < state.collection.size
145
+ };
67
146
  };
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;
147
+
148
+ setTagState(function *() {
149
+ // Update to show all items.
150
+ yield {visibleTagCount: state.collection.size, showCollapseButton: true};
151
+
152
+ // Measure, and update to show the items until maxRows is reached.
153
+ yield computeVisibleTagCount();
154
+ });
155
+ }
156
+ }, [maxRows, setTagState, direction, scale, state.collection.size]);
157
+
158
+ useResizeObserver({ref: containerRef, onResize: updateVisibleTagCount});
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ useLayoutEffect(updateVisibleTagCount, [children]);
161
+
162
+ useEffect(() => {
163
+ // Recalculate visible tags when fonts are loaded.
164
+ document.fonts?.ready.then(() => updateVisibleTagCount());
165
+ // eslint-disable-next-line react-hooks/exhaustive-deps
166
+ }, []);
167
+
168
+ let visibleTags = useMemo(() =>
169
+ [...state.collection].slice(0, isCollapsed ? tagState.visibleTagCount : state.collection.size),
170
+ [isCollapsed, state.collection, tagState.visibleTagCount]
171
+ );
172
+
173
+ let handlePressCollapse = () => {
174
+ // Prevents button from losing focus if focusedKey got collapsed.
175
+ state.selectionManager.setFocusedKey(null);
176
+ setIsCollapsed(prevCollapsed => !prevCollapsed);
177
+ };
178
+
179
+ let showActions = tagState.showCollapseButton || (actionLabel && onAction);
180
+ let isEmpty = state.collection.size === 0;
181
+
182
+ let containerStyle = useMemo(() => {
183
+ if (maxRows == null || !isCollapsed || isEmpty) {
184
+ return undefined;
185
+ }
186
+ let maxHeight = (TAG_STYLES[scale].height + (TAG_STYLES[scale].margin * 2)) * maxRows;
187
+ return {maxHeight, overflow: 'hidden'};
188
+ }, [isCollapsed, maxRows, isEmpty, scale]);
189
+
91
190
  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>
191
+ <FocusScope>
192
+ <Field
193
+ {...props}
194
+ labelProps={labelProps}
195
+ descriptionProps={descriptionProps}
196
+ errorMessageProps={errorMessageProps}
197
+ showErrorIcon
198
+ ref={domRef}
199
+ elementType="span"
200
+ wrapperClassName={
201
+ classNames(
202
+ styles,
203
+ 'spectrum-Tags-fieldWrapper',
204
+ {
205
+ 'spectrum-Tags-fieldWrapper--positionSide': labelPosition === 'side'
206
+ }
207
+ )
208
+ }>
209
+ <div
210
+ ref={containerRef}
211
+ style={containerStyle}
212
+ className={
213
+ classNames(
214
+ styles,
215
+ 'spectrum-Tags-container',
216
+ {
217
+ 'spectrum-Tags-container--empty': isEmpty
218
+ }
219
+ )
220
+ }>
221
+ <FocusRing focusRingClass={classNames(styles, 'focus-ring')}>
222
+ <div
223
+ ref={tagsRef}
224
+ {...gridProps}
225
+ className={classNames(styles, 'spectrum-Tags')}>
226
+ {visibleTags.map(item => (
227
+ <Tag
228
+ {...item.props}
229
+ key={item.key}
230
+ item={item}
231
+ state={state}>
232
+ {item.rendered}
233
+ </Tag>
234
+ ))}
235
+ {isEmpty && (
236
+ <div className={classNames(styles, 'spectrum-Tags-empty-state')}>
237
+ {renderEmptyState()}
238
+ </div>
239
+ )}
240
+ </div>
241
+ </FocusRing>
242
+ {showActions && !isEmpty &&
243
+ <Provider isDisabled={false}>
244
+ <div
245
+ role="group"
246
+ ref={actionsRef}
247
+ id={actionsId}
248
+ aria-label={stringFormatter.format('actions')}
249
+ aria-labelledby={`${gridProps.id} ${actionsId}`}
250
+ className={classNames(styles, 'spectrum-Tags-actions')}>
251
+ {tagState.showCollapseButton &&
252
+ <ActionButton
253
+ isQuiet
254
+ onPress={handlePressCollapse}
255
+ UNSAFE_className={classNames(styles, 'spectrum-Tags-actionButton')}>
256
+ {isCollapsed ?
257
+ stringFormatter.format('showAllButtonLabel', {tagCount: state.collection.size}) :
258
+ stringFormatter.format('hideButtonLabel')
259
+ }
260
+ </ActionButton>
261
+ }
262
+ {actionLabel && onAction &&
263
+ <ActionButton
264
+ isQuiet
265
+ onPress={() => onAction?.()}
266
+ UNSAFE_className={classNames(styles, 'spectrum-Tags-actionButton')}>
267
+ {actionLabel}
268
+ </ActionButton>
269
+ }
270
+ </div>
271
+ </Provider>
272
+ }
273
+ </div>
274
+ </Field>
275
+ </FocusScope>
118
276
  );
119
277
  }
120
278
 
279
+ /** Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. */
121
280
  const _TagGroup = React.forwardRef(TagGroup) as <T>(props: SpectrumTagGroupProps<T> & {ref?: DOMRef<HTMLDivElement>}) => ReactElement;
122
281
  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';