diff --git a/packages/@adobe/spectrum-css-temp/components/tags/index.css b/packages/@adobe/spectrum-css-temp/components/tags/index.css index 28d36d50e42..40a43edd662 100644 --- a/packages/@adobe/spectrum-css-temp/components/tags/index.css +++ b/packages/@adobe/spectrum-css-temp/components/tags/index.css @@ -12,13 +12,12 @@ governing permissions and limitations under the License. @import '../commons/index.css'; -.spectrum-Tags { - display: flex; - flex-wrap: wrap; +:root { + --spectrum-tag-margin: calc(var(--spectrum-taggroup-tag-gap-y) / 2) calc(var(--spectrum-taggroup-tag-gap-x) / 2); +} - margin: 0; - padding: 0; - list-style: none; +.spectrum-Tags { + display: inline; } .spectrum-Tags-item { @@ -26,15 +25,15 @@ governing permissions and limitations under the License. --spectrum-focus-ring-border-radius: var(--spectrum-tag-border-radius); --spectrum-focus-ring-border-size: var(--spectrum-tag-border-size); - display: grid; + display: inline-grid; grid-template-columns: auto 1fr auto; grid-template-areas: "icon content action"; align-items: center; box-sizing: border-box; position: relative; - margin: calc(var(--spectrum-taggroup-tag-gap-y) / 2) calc(var(--spectrum-taggroup-tag-gap-x) / 2); - padding-inline-start:calc(var(--spectrum-tag-padding-x) - var(--spectrum-tag-border-size)); + margin: var(--spectrum-tag-margin); + padding-inline-start: calc(var(--spectrum-tag-padding-x) - var(--spectrum-tag-border-size)); block-size: var(--spectrum-tag-height); max-inline-size: 100%; @@ -91,3 +90,15 @@ governing permissions and limitations under the License. } } +.spectrum-Tags-actionButton { + display: inline; + height: var(--spectrum-tag-height); + font-size: var(--spectrum-tag-text-size); + margin: var(--spectrum-tag-margin); + + > span { + padding-inline-start: var(--spectrum-tag-padding-x); + padding-inline-end: var(--spectrum-tag-padding-x); + } +} + diff --git a/packages/@react-aria/tag/package.json b/packages/@react-aria/tag/package.json index f8a69edc127..945934d639b 100644 --- a/packages/@react-aria/tag/package.json +++ b/packages/@react-aria/tag/package.json @@ -21,6 +21,7 @@ "@react-aria/i18n": "^3.6.3", "@react-aria/interactions": "^3.13.1", "@react-aria/utils": "^3.14.2", + "@react-stately/list": "^3.6.1", "@react-stately/tag": "3.0.0-alpha.1", "@react-types/button": "^3.7.0", "@react-types/shared": "^3.16.0", diff --git a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts index 3f78d483f10..8e46e4238bd 100644 --- a/packages/@react-aria/tag/src/TagKeyboardDelegate.ts +++ b/packages/@react-aria/tag/src/TagKeyboardDelegate.ts @@ -46,6 +46,7 @@ export class TagKeyboardDelegate implements KeyboardDelegate { // Find the next item key = this.collection.getKeyAfter(key); + if (key != null) { return key; } else { diff --git a/packages/@react-aria/tag/src/index.ts b/packages/@react-aria/tag/src/index.ts index d7d10b431ff..d723ad97dab 100644 --- a/packages/@react-aria/tag/src/index.ts +++ b/packages/@react-aria/tag/src/index.ts @@ -15,5 +15,5 @@ export {useTag} from './useTag'; export {useTagGroup} from './useTagGroup'; export type {TagProps} from '@react-types/tag'; -export type {TagGroupAria} from './useTagGroup'; +export type {TagGroupAria, AriaTagGroupProps} from './useTagGroup'; export type {TagAria} from './useTag'; diff --git a/packages/@react-aria/tag/src/useTagGroup.ts b/packages/@react-aria/tag/src/useTagGroup.ts index 8c94126cc72..c2d4e8146d4 100644 --- a/packages/@react-aria/tag/src/useTagGroup.ts +++ b/packages/@react-aria/tag/src/useTagGroup.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {AriaTagGroupProps} from '@react-types/tag'; -import {DOMAttributes} from '@react-types/shared'; +import {AriaLabelingProps, DOMAttributes, DOMProps} from '@react-types/shared'; import {filterDOMProps, mergeProps} from '@react-aria/utils'; import {RefObject, useState} from 'react'; +import {TagGroupProps} from '@react-types/tag'; import type {TagGroupState} from '@react-stately/tag'; import {TagKeyboardDelegate} from './TagKeyboardDelegate'; import {useFocusWithin} from '@react-aria/interactions'; @@ -24,6 +24,14 @@ export interface TagGroupAria { tagGroupProps: DOMAttributes } +export interface AriaTagGroupProps extends TagGroupProps, DOMProps, AriaLabelingProps { + /** + * An optional keyboard delegate to handle arrow key navigation, + * to override the default. + */ + keyboardDelegate?: TagKeyboardDelegate +} + /** * 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. @@ -33,7 +41,7 @@ export interface TagGroupAria { */ export function useTagGroup(props: AriaTagGroupProps, state: TagGroupState, ref: RefObject): TagGroupAria { let {direction} = useLocale(); - let keyboardDelegate = new TagKeyboardDelegate(state.collection, direction); + let keyboardDelegate = props.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 @@ -51,6 +59,6 @@ export function useTagGroup(props: AriaTagGroupProps, state: TagGroupState 'aria-relevant': 'additions', 'aria-live': isFocusWithin ? 'polite' : 'off', ...focusWithinProps - } as DOMAttributes) + }) }; } diff --git a/packages/@react-spectrum/tag/intl/en-US.json b/packages/@react-spectrum/tag/intl/en-US.json new file mode 100644 index 00000000000..872c357ed6e --- /dev/null +++ b/packages/@react-spectrum/tag/intl/en-US.json @@ -0,0 +1,4 @@ +{ + "showAllButtonLabel": "Show all ({tagCount})", + "hideButtonLabel": "Show less" +} \ No newline at end of file diff --git a/packages/@react-spectrum/tag/package.json b/packages/@react-spectrum/tag/package.json index 03a1ff08d9a..c153392cc1e 100644 --- a/packages/@react-spectrum/tag/package.json +++ b/packages/@react-spectrum/tag/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@react-aria/focus": "^3.10.1", + "@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", @@ -39,6 +40,7 @@ "@react-spectrum/text": "^3.3.4", "@react-spectrum/utils": "^3.8.1", "@react-stately/collections": "^3.5.1", + "@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", diff --git a/packages/@react-spectrum/tag/src/TagGroup.tsx b/packages/@react-spectrum/tag/src/TagGroup.tsx index d3f1fe0834b..3e6ae36102f 100644 --- a/packages/@react-spectrum/tag/src/TagGroup.tsx +++ b/packages/@react-spectrum/tag/src/TagGroup.tsx @@ -10,52 +10,161 @@ * governing permissions and limitations under the License. */ +import {ActionButton} from '@react-spectrum/button'; +import {AriaTagGroupProps, TagKeyboardDelegate, useTagGroup} from '@react-aria/tag'; import {classNames, useDOMRef, useStyleProps} from '@react-spectrum/utils'; -import {DOMRef} from '@react-types/shared'; -import {mergeProps} from '@react-aria/utils'; -import React, {ReactElement} from 'react'; -import {SpectrumTagGroupProps} from '@react-types/tag'; +import {DOMRef, StyleProps} from '@react-types/shared'; +import {FocusScope} from '@react-aria/focus'; +// @ts-ignore +import intlMessages from '../intl/*.json'; +import {ListCollection} from '@react-stately/list'; +import React, {ReactElement, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styles from '@adobe/spectrum-css-temp/components/tags/vars.css'; import {Tag} from './Tag'; +import {useLayoutEffect, useResizeObserver, useValueEffect} from '@react-aria/utils'; +import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n'; import {useProviderProps} from '@react-spectrum/provider'; -import {useTagGroup} from '@react-aria/tag'; import {useTagGroupState} from '@react-stately/tag'; +export interface SpectrumTagGroupProps extends AriaTagGroupProps, StyleProps {} + function TagGroup(props: SpectrumTagGroupProps, ref: DOMRef) { props = useProviderProps(props); let { allowsRemoving, onRemove, + maxRows, + children, ...otherProps } = props; let domRef = useDOMRef(ref); + let containerRef = useRef(null); let {styleProps} = useStyleProps(otherProps); + let {direction} = useLocale(); + let stringFormatter = useLocalizedStringFormatter(intlMessages); + let [isCollapsed, setIsCollapsed] = useState(maxRows != null); let state = useTagGroupState(props); - let {tagGroupProps} = useTagGroup(props, state, domRef); + let [tagState, setTagState] = useValueEffect({visibleTagCount: state.collection.size, showCollapseButton: false}); + let keyboardDelegate = useMemo(() => ( + isCollapsed + ? new TagKeyboardDelegate(new ListCollection([...state.collection].slice(0, tagState.visibleTagCount)), direction) + : new TagKeyboardDelegate(new ListCollection([...state.collection]), direction) + ), [direction, isCollapsed, state.collection, tagState.visibleTagCount]) as TagKeyboardDelegate; + let {tagGroupProps} = useTagGroup({...props, keyboardDelegate}, state, domRef); + + let updateVisibleTagCount = useCallback(() => { + if (maxRows > 0) { + let computeVisibleTagCount = () => { + // Refs can be null at runtime. + let currDomRef: HTMLDivElement | null = domRef.current; + let currContainerRef: HTMLDivElement | null = containerRef.current; + if (!currDomRef || !currContainerRef) { + return; + } + + let tags = [...currDomRef.children]; + let button = currContainerRef.querySelector('button'); + let currY = -Infinity; + let rowCount = 0; + let index = 0; + let tagWidths = []; + // Count rows and show tags until we hit the maxRows. + for (let tag of tags) { + let {width, y} = tag.getBoundingClientRect(); + + if (y !== currY) { + currY = y; + rowCount++; + } + + if (rowCount > maxRows) { + break; + } + tagWidths.push(width); + index++; + } + + // Remove tags until there is space for the collapse button on the last row. + let buttonWidth = button.getBoundingClientRect().width; + let end = direction === 'ltr' ? 'right' : 'left'; + let containerEnd = currContainerRef.getBoundingClientRect()[end]; + let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end]; + let availableWidth = containerEnd - lastTagEnd; + for (let tagWidth of tagWidths.reverse()) { + if (availableWidth >= buttonWidth || index <= 1 || index >= state.collection.size) { + break; + } + availableWidth += tagWidth; + index--; + } + return {visibleTagCount: index, showCollapseButton: index < state.collection.size}; + }; + + setTagState(function *() { + // Update to show all items. + yield {visibleTagCount: state.collection.size, showCollapseButton: true}; + + // Measure, and update to show the items until maxRows is reached. + yield computeVisibleTagCount(); + }); + } + }, [maxRows, setTagState, domRef, direction, state.collection.size]); + + useResizeObserver({ref: containerRef, onResize: updateVisibleTagCount}); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(updateVisibleTagCount, [children]); + + useEffect(() => { + // Recalculate visible tags when fonts are loaded. + document.fonts?.ready.then(() => updateVisibleTagCount()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + let visibleTags = [...state.collection]; + if (maxRows != null && isCollapsed) { + visibleTags = visibleTags.slice(0, tagState.visibleTagCount); + } + + let handlePressCollapse = () => { + // Prevents button from losing focus if focusedKey got collapsed. + state.selectionManager.setFocusedKey(null); + setIsCollapsed(prevCollapsed => !prevCollapsed); + }; + return ( -
- {[...state.collection].map(item => ( - - {item.rendered} - - ))} -
+ +
+
+ {visibleTags.map(item => ( + + {item.rendered} + + ))} +
+ {tagState.showCollapseButton && + + {isCollapsed ? stringFormatter.format('showAllButtonLabel', {tagCount: state.collection.size}) : stringFormatter.format('hideButtonLabel')} + + } +
+
); } diff --git a/packages/@react-spectrum/tag/src/index.ts b/packages/@react-spectrum/tag/src/index.ts index fa14cf4eccb..59f733c31e1 100644 --- a/packages/@react-spectrum/tag/src/index.ts +++ b/packages/@react-spectrum/tag/src/index.ts @@ -12,4 +12,4 @@ export {TagGroup} from './TagGroup'; export {Item} from '@react-stately/collections'; -export type {SpectrumTagGroupProps} from '@react-types/tag'; +export type {SpectrumTagGroupProps} from './TagGroup'; diff --git a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx index 84df182a7b7..78035577c9b 100644 --- a/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx +++ b/packages/@react-spectrum/tag/stories/TagGroup.stories.tsx @@ -10,90 +10,146 @@ * governing permissions and limitations under the License. */ +import {action} from '@storybook/addon-actions'; import Audio from '@spectrum-icons/workflow/Audio'; -import {Item, TagGroup} from '../src'; +import {ComponentMeta, ComponentStoryObj} from '@storybook/react'; +import {Item, SpectrumTagGroupProps, 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'}]; +let manyItems = []; +for (let i = 0; i < 50; i++) { + let item = {key: i}; + manyItems.push(item); +} -storiesOf('TagGroup', module) - .add( - 'default', - () => render({}) - ) - .add('icons', () => ( - - {item => ( +function ResizableContainer({children}) { + return ( +
+ {children} +
+ ); +} + +function render(props: SpectrumTagGroupProps) { + return ( + + Cool Tag 1 + Cool Tag 2 + Cool Tag 3 + + ); +} + +export default { + title: 'TagGroup', + component: TagGroup, + argTypes: { + onRemove: { + table: { + disable: true + } + }, + maxRows: {type: 'number'} + }, + render: args => render(args) +} as ComponentMeta; + +export type TagGroupStory = ComponentStoryObj; + +export const Default: TagGroupStory = {}; + +export const WithIcons: TagGroupStory = { + args: {items: [{key: '1', label: 'Cool Tag 1'}, {key: '2', label: 'Cool Tag 2'}]}, + render: (args) => ( + + {(item: any) => ( )} - )) - .add( - 'onRemove', - () => { - 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'} - ]); - - let removeItem = (key) => { - setItems(prevItems => prevItems.filter((item) => key !== item.id)); - }; - - return ( - - {item => {item.label}} - - ); - } - ) - .add('wrapping', () => ( -
- - Cool Tag 1 - Another cool tag - This tag - What tag? - This tag is cool too - Shy tag - -
- ) ) - .add('label truncation', () => ( +}; + +export const OnRemove: TagGroupStory = { + render: (args) => , + storyName: 'onRemove' +}; + +export const Wrapping: TagGroupStory = { + decorators: [(Story) => {}] +}; + +export const LabelTruncation: TagGroupStory = { + render: (args) => (
- + Cool Tag 1 with a really long label Another long cool tag label This tag
- ) ) - .add( - 'dynamic items', - () => ( - - {item => {item.label}} - - ) - ); +}; -function render(props: any = {}) { - return ( - +export const MaxRows: TagGroupStory = { + args: {maxRows: 2}, + render: (args) => ( + Cool Tag 1 - Cool Tag 2 - Cool Tag 3 + Another cool tag + This tag + What tag? + This tag is cool too + Shy tag + + ), + decorators: [(Story) => {}], + storyName: 'maxRows' +}; + +export const MaxRowsManyTags: TagGroupStory = { + args: {maxRows: 2}, + render: (args) => ( + + {(item: any) => ( + {`Tag ${item.key}`} + )} + + ), + decorators: [(Story) => {}], + storyName: 'maxRows with many tags' +}; + +export const MaxRowsOnRemove: TagGroupStory = { + args: {maxRows: 2}, + render: (args) => , + decorators: [(Story) => {}], + storyName: 'maxRows + onRemove' +}; + +function OnRemoveExample(props) { + 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'} + ]); + + let onRemove = (key) => { + setItems(prevItems => prevItems.filter((item) => key !== item.id)); + action('onRemove')(key); + }; + + return ( + onRemove(key)} {...props}> + {(item: any) => ( + {item.label} + )} ); } diff --git a/packages/@react-spectrum/tag/test/TagGroup.test.js b/packages/@react-spectrum/tag/test/TagGroup.test.js index d5bfdf1dcca..5552c77c98c 100644 --- a/packages/@react-spectrum/tag/test/TagGroup.test.js +++ b/packages/@react-spectrum/tag/test/TagGroup.test.js @@ -397,4 +397,72 @@ describe('TagGroup', function () { pressArrowRight(tags[0]); expect(document.activeElement).toBe(tags[1]); }); + + 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})) + .mockImplementationOnce(() => ({x: 275, y: 300, width: 110, height: 32, top: 300, right: 385, bottom: 335, left: 275})) + .mockImplementationOnce(() => ({x: 200, y: 335, width: 65, height: 32, top: 335, right: 265, bottom: 370, left: 200})) + .mockImplementationOnce(() => ({x: 265, y: 335, width: 75, height: 32, top: 335, right: 345, bottom: 370, left: 265})) + .mockImplementationOnce(() => ({x: 200, y: 370, width: 120, height: 32, top: 370, right: 320, bottom: 400, left: 200})) + .mockImplementationOnce(() => ({x: 200, y: 400, width: 95, height: 32, top: 400, right: 290, bottom: 435, left: 200})) + .mockImplementationOnce(() => ({x: 200, y: 300, width: 200, height: 128, top: 300, right: 400, bottom: 435, left: 200})) + .mockImplementationOnce(() => ({x: 265, y: 335, width: 75, height: 32, top: 335, right: 345, bottom: 370, left: 265})); + let {getAllByRole, getByRole} = render( + + + Tag 1 + Tag 2 + Tag 3 + Tag 4 + Tag 5 + Tag 6 + Tag 7 + + + ); + + let tags = getAllByRole('gridcell'); + expect(tags.length).toBe(3); + + let button = getByRole('button'); + expect(button).toHaveTextContent('Show all (7)'); + + userEvent.click(button); + tags = getAllByRole('gridcell'); + expect(tags.length).toBe(7); + expect(button).toHaveTextContent('Show less'); + + userEvent.click(button); + tags = getAllByRole('gridcell'); + expect(tags.length).toBe(3); + expect(button).toHaveTextContent('Show all (7)'); + + offsetWidth.mockReset(); + }); + + it('maxRows should not show button if there is enough room to show all tags', 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})) + .mockImplementationOnce(() => ({x: 275, y: 300, width: 110, height: 32, top: 300, right: 385, bottom: 335, left: 275})) + .mockImplementationOnce(() => ({x: 200, y: 335, width: 65, height: 32, top: 335, right: 265, bottom: 370, left: 200})) + .mockImplementationOnce(() => ({x: 265, y: 335, width: 75, height: 32, top: 335, right: 345, bottom: 370, left: 265})) + .mockImplementationOnce(() => ({x: 200, y: 370, width: 120, height: 32, top: 370, right: 320, bottom: 400, left: 200})); + let {getAllByRole, queryAllByRole} = render( + + + Tag 1 + Tag 2 + + + ); + + let tags = getAllByRole('gridcell'); + expect(tags.length).toBe(2); + + let buttons = queryAllByRole('button'); + expect(buttons.length).toBe(0); + + offsetWidth.mockReset(); + }); }); diff --git a/packages/@react-types/tag/src/index.d.ts b/packages/@react-types/tag/src/index.d.ts index abf451cf028..3632f49c130 100644 --- a/packages/@react-types/tag/src/index.d.ts +++ b/packages/@react-types/tag/src/index.d.ts @@ -10,20 +10,18 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, CollectionBase, DOMProps, ItemProps, Node, StyleProps} from '@react-types/shared'; +import {CollectionBase, ItemProps, Node} from '@react-types/shared'; import {Key, RefObject} from 'react'; export interface TagGroupProps extends Omit, 'disabledKeys'> { /** Whether the TagGroup allows removal of tags. */ allowsRemoving?: boolean, /** Called when the user removes a tag. */ - onRemove?: (key: Key) => void + onRemove?: (key: Key) => void, + /** Limit the number of rows initially shown. This will render a button that allows the user to expand to show all tags. */ + maxRows?: number } -export interface AriaTagGroupProps extends TagGroupProps, DOMProps, AriaLabelingProps {} - -export interface SpectrumTagGroupProps extends AriaTagGroupProps, StyleProps {} - export interface TagProps extends ItemProps { isFocused: boolean, allowsRemoving?: boolean,