From 16176b89bb937771ab43e4b595377fb7e3fc503c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 16 Aug 2024 07:48:59 +1000 Subject: [PATCH] S2 TagGroup collapse (#6795) --- .../custom-addons/provider/register.js | 1 - packages/@react-spectrum/s2/package.json | 1 + .../@react-spectrum/s2/src/Breadcrumbs.tsx | 9 +- packages/@react-spectrum/s2/src/TagGroup.tsx | 421 ++++++++++++++---- packages/@react-spectrum/s2/src/Tooltip.tsx | 15 +- .../s2/stories/TagGroup.stories.tsx | 63 ++- yarn.lock | 1 + 7 files changed, 407 insertions(+), 104 deletions(-) diff --git a/.storybook-s2/custom-addons/provider/register.js b/.storybook-s2/custom-addons/provider/register.js index c3ea940617e..ec994ea2f60 100644 --- a/.storybook-s2/custom-addons/provider/register.js +++ b/.storybook-s2/custom-addons/provider/register.js @@ -55,7 +55,6 @@ addons.register('ProviderSwitcher', (api) => { title: 'viewport', type: types.TOOL, match: ({ viewMode }) => { - console.log('viewMode', viewMode); return viewMode === 'story' || viewMode === 'docs' }, render: () => , diff --git a/packages/@react-spectrum/s2/package.json b/packages/@react-spectrum/s2/package.json index e35a9b65c1b..4cdbd23dc62 100644 --- a/packages/@react-spectrum/s2/package.json +++ b/packages/@react-spectrum/s2/package.json @@ -124,6 +124,7 @@ "@parcel/macros": "2.12.1-canary.3165" }, "dependencies": { + "@react-aria/collections": "3.0.0-alpha.3", "@react-aria/i18n": "^3.11.0", "@react-aria/interactions": "^3.22.0", "@react-aria/utils": "^3.23.0", diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index e964f3249ab..d00b5447c8b 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -14,10 +14,11 @@ import {Breadcrumb as AriaBreadcrumb, BreadcrumbsProps as AriaBreadcrumbsProps, import {AriaBreadcrumbItemProps, useLocale} from 'react-aria'; import ChevronIcon from '../ui-icons/Chevron'; import {createContext, forwardRef, ReactNode, useRef} from 'react'; -import {DOMRefValue, LinkDOMProps} from '@react-types/shared'; +import {DOMRef, DOMRefValue, LinkDOMProps} from '@react-types/shared'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {forwardRefType} from './types'; import {size, style} from '../style/spectrum-theme' with { type: 'macro' }; +import {useDOMRef} from '@react-spectrum/utils'; interface BreadcrumbsStyleProps { /** @@ -69,7 +70,7 @@ const wrapper = style({ } }, getAllowedOverrides()); -function Breadcrumbs(props: BreadcrumbsProps) { +function Breadcrumbs(props: BreadcrumbsProps, ref: DOMRef) { let { UNSAFE_className = '', UNSAFE_style, @@ -78,11 +79,11 @@ function Breadcrumbs(props: BreadcrumbsProps) { children, ...otherProps } = props; - let ref = useRef(null); + let domRef = useDOMRef(ref); return ( extends Omit void } export const TagGroupContext = createContext, DOMRefValue>>(null); @@ -89,33 +100,162 @@ const helpTextStyles = style({ cursor: 'text' }); -function TagGroup( - props: TagGroupProps, - ref: DOMRef -) { - let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); +const InternalTagGroupContext = createContext>({}); + +function TagGroup(props: TagGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, TagGroupContext); - let { + props = useFormProps(props); + let {onRemove} = props; + return ( + + }> + {collection => } + + + ); +} + +/** Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. */ +let _TagGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(TagGroup); +export {_TagGroup as TagGroup}; + +function TagGroupInner({ + props: { label, description, - items, labelPosition = 'top', labelAlign = 'start', - children, - renderEmptyState = () => stringFormatter.format('tag.noTags'), isEmphasized, isInvalid, errorMessage, UNSAFE_className = '', UNSAFE_style, size = 'M', + ...props + }, + forwardedRef: ref, + collection +}: {props: TagGroupProps, forwardedRef: DOMRef, collection: any}) { + let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2'); + let { + maxRows, + groupActionLabel, + onGroupAction, + renderEmptyState = () => stringFormatter.format('tag.noTags'), ...otherProps } = props; - + let {direction} = useLocale(); + let containerRef = useRef(null); + let tagsRef = useRef(null); + let actionsRef = useRef(null); + let hiddenTagsRef = useRef(null); + let [tagState, setTagState] = useState({visibleTagCount: collection.size, showCollapseButton: false}); + let [isCollapsed, setIsCollapsed] = useState(maxRows != null); + let {onRemove} = useContext(InternalTagGroupContext); + let isEmpty = collection.size === 0; + let showCollapseToggleButton = tagState.showCollapseButton || tagState.visibleTagCount < collection.size; let formContext = useContext(FormContext); - props = useFormProps(props); let domRef = useDOMRef(ref); + let allItems = useMemo( + () => Array.from(collection) as Array>, + [collection] + ); + let items = useMemo( + () => Array.from(collection).slice(0, !isCollapsed ? collection.size : tagState.visibleTagCount) as Array>, + [collection, tagState.visibleTagCount, isCollapsed] + ); + + let updateVisibleTagCount = useEffectEvent(() => { + if (maxRows == null) { + setTagState({visibleTagCount: collection.size, showCollapseButton: false}); + } + + if (maxRows != null && maxRows > 0) { + let computeVisibleTagCount = () => { + let currContainerRef: HTMLDivElement | null = hiddenTagsRef.current; + let currTagsRef: HTMLDivElement | null = hiddenTagsRef.current; + let currActionsRef: HTMLDivElement | null = actionsRef.current; + if (!currContainerRef || !currTagsRef || collection.size === 0 || currContainerRef.parentElement == null) { + return { + visibleTagCount: 0, + showCollapseButton: false + }; + } + + // Count rows and show tags until we hit the maxRows. + // I think this is still a safe assumption, and we don't need to queryAll for role=tag + let tags = [...currTagsRef.children]; + let currY = -Infinity; + let rowCount = 0; + let index = 0; + let tagWidths: number[] = []; + 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 and action button (if present) on the last row. + let buttons = currActionsRef ? [...currActionsRef.children] : []; + if (buttons.length > 0 && rowCount >= maxRows) { + let buttonsWidth = buttons.reduce((acc, curr) => acc += curr.getBoundingClientRect().width, 0); + let margins = parseFloat(getComputedStyle(buttons[0]).marginInlineStart); + buttonsWidth += margins * 2; + let end = direction === 'ltr' ? 'right' : 'left'; + let containerEnd = currContainerRef.parentElement?.getBoundingClientRect()[end] - margins; + let lastTagEnd = tags[index - 1]?.getBoundingClientRect()[end]; + lastTagEnd += margins; + let availableWidth = containerEnd - lastTagEnd; + + while (availableWidth <= buttonsWidth && index > 0) { + let tagWidth = tagWidths.pop(); + if (tagWidth != null) { + availableWidth += tagWidth; + } + index--; + } + } + + return { + visibleTagCount: Math.max(index, 1), + showCollapseButton: index < collection.size + }; + }; + let result = computeVisibleTagCount(); + flushSync(() => { + setTagState(result); + }); + } + }); + + useResizeObserver({ref: maxRows != null ? containerRef : undefined, onResize: updateVisibleTagCount}); + + useLayoutEffect(() => { + if (collection.size > 0 && (maxRows != null && maxRows > 0)) { + queueMicrotask(updateVisibleTagCount); + } + }, [collection.size, updateVisibleTagCount, maxRows]); + + useEffect(() => { + // Recalculate visible tags when fonts are loaded. + document.fonts?.ready.then(() => updateVisibleTagCount()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + let handlePressCollapse = () => { + setIsCollapsed(prevCollapsed => !prevCollapsed); + }; + let helpText: ReactNode = null; if (!isInvalid && description) { helpText = ( @@ -139,15 +279,13 @@ function TagGroup( ); } - // TODO collapse behavior, need a custom collection render so we can limit the number of children - // but this isn't possible yet return ( @@ -159,35 +297,83 @@ function TagGroup( {label}
+ marginStart: { + default: -4, + isEmpty: 0 + }, + marginEnd: { + default: 4, + isEmpty: 0 + }, + position: 'relative' + })({isEmpty})}> + {/* invisible collection for measuring */} + {maxRows != null && ( +
+ {allItems.map(item => { + // pull off individual props as an allow list, don't want refs or other props getting through + // possibly should render a tag look alike instead though, so i don't call the hooks either or add id's to elements etc + return ( +
+ {item.props.children({size, allowsRemoving: Boolean(onRemove), isInCtx: true})} +
+ ); + })} +
+ )} + {/* real tag list */} style({ - marginX: { - default: -4, // use negative number when theme TS is ready - isEmpty: 0 - }, - display: 'flex', + className={style({ + display: 'inline', minWidth: 'full', - flexWrap: 'wrap', font: 'ui' - })({isEmpty})}> - {children} + })}> + {item => <_Tag {...item.props} id={item.key} textValue={item.textValue} />} + {!isEmpty && (showCollapseToggleButton || groupActionLabel) && + + }
@@ -196,13 +382,61 @@ function TagGroup( ); } -/** Tags allow users to categorize content. They can represent keywords or people, and are grouped to describe an item or a search request. */ -let _TagGroup = /*#__PURE__*/ (forwardRef as forwardRefType)(TagGroup); -export {_TagGroup as TagGroup}; +function ActionGroup(props) { + let { + actionsRef, + tagState, + size, + isCollapsed, + handlePressCollapse, + onGroupAction, + groupActionLabel, + // directly use aria-labelling from the TagGroup because we can't use the id from the TagList + // and we can't supply an id to the TagList because it'll cause an issue where all the tag ids flip back + // and forth with their prefix in an infinite loop + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy + } = props; + + let actionsId = useId(); + return ( +
+ {tagState.showCollapseButton && + + {isCollapsed ? 'Show all' : 'Collapse'} + + } + {groupActionLabel && onGroupAction && + onGroupAction?.()}> + {groupActionLabel} + + } +
+ ); +} const tagStyles = style({ ...focusRing(), display: 'inline-flex', + verticalAlign: 'middle', alignItems: 'center', justifyContent: 'center', font: 'control', @@ -276,63 +510,82 @@ const avatarSize = { L: 24 } as const; -export function Tag({children, ...props}: TagProps) { - let textValue = typeof children === 'string' ? children : undefined; - let {size = 'M', isEmphasized} = useSlottedContext(TagGroupContext)!; +function Tag({children, textValue, ...props}: TagProps, ref: DOMRef) { + textValue ||= typeof children === 'string' ? children : undefined; + let ctx = useSlottedContext(TagGroupContext); + let isInRealDOM = Boolean(ctx?.size); + let {size, isEmphasized} = ctx ?? {}; + let domRef = useDOMRef(ref); - let ref = useRef(null); + let backupRef = useRef(null); + domRef = domRef || backupRef; let isLink = props.href != null; return ( tagStyles({...renderProps, size, isEmphasized, isLink})} > - {composeRenderProps(children, (children, {allowsRemoving, isDisabled}) => ( - <> -
- - {typeof children === 'string' ? {children} : children} - -
- {allowsRemoving && ( - - )} - + ref={domRef} + style={pressScale(domRef)} + className={renderProps => tagStyles({size, isEmphasized, isLink, ...renderProps})} > + {composeRenderProps(children, (children, renderProps) => ( + {typeof children === 'string' ? {children} : children} ))}
); } + + +/** An individual Tag for TagGroups. */ +let _Tag = /*#__PURE__*/ (forwardRef as forwardRefType)(Tag); +export {_Tag as Tag}; + +function TagWrapper({children, isDisabled, allowsRemoving, isInRealDOM}) { + let {size} = useSlottedContext(TagGroupContext) ?? {}; + return ( + <> + {isInRealDOM && ( +
+ + {children} + +
+ )} + {!isInRealDOM && children} + {allowsRemoving && isInRealDOM && ( + + )} + + ); +} diff --git a/packages/@react-spectrum/s2/src/Tooltip.tsx b/packages/@react-spectrum/s2/src/Tooltip.tsx index 103e5dd61e8..e5bf123ef5b 100644 --- a/packages/@react-spectrum/s2/src/Tooltip.tsx +++ b/packages/@react-spectrum/s2/src/Tooltip.tsx @@ -189,7 +189,12 @@ function Tooltip(props: TooltipProps, ref: DOMRef) { ); } -function TooltipTrigger(props: TooltipTriggerProps) { +/** + * TooltipTrigger wraps around a trigger element and a Tooltip. It handles opening and closing + * the Tooltip when the user hovers over or focuses the trigger, and positioning the Tooltip + * relative to the trigger. + */ +export function TooltipTrigger(props: TooltipTriggerProps) { let { containerPadding, crossOffset, @@ -215,14 +220,6 @@ function TooltipTrigger(props: TooltipTriggerProps) { ); } -/** - * TooltipTrigger wraps around a trigger element and a Tooltip. It handles opening and closing - * the Tooltip when the user hovers over or focuses the trigger, and positioning the Tooltip - * relative to the trigger. - */ -let _TooltipTrigger = forwardRef(TooltipTrigger); -export {_TooltipTrigger as TooltipTrigger}; - /** * Display container for Tooltip content. Has a directional arrow dependent on its placement. diff --git a/packages/@react-spectrum/s2/stories/TagGroup.stories.tsx b/packages/@react-spectrum/s2/stories/TagGroup.stories.tsx index 81668c6d913..f8c924772dc 100644 --- a/packages/@react-spectrum/s2/stories/TagGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TagGroup.stories.tsx @@ -53,12 +53,32 @@ export let Example = { args.onRemove = action('remove'); } return ( - - Chocolate - Mint - Strawberry - Vanilla - +
+ + Chocolate + Mint + Strawberry + Vanilla + Cookie dough + Rose + Nutella + Pistachio + Oreo + Caramel + Peanut butter + Cinnamon + Cardamom + Licorice + Marshmallow + Coffee + Toffee + Bubblegum + Peach + Raspberry + Strawberry + Blackberry + +
); }, args: { @@ -68,6 +88,37 @@ export let Example = { } }; +interface ITagItem { + name: string, + id: string +} +let items: Array = [ + {name: 'Chocolate', id: 'chocolate'}, + {name: 'Mint', id: 'mint'}, + {name: 'Strawberry', id: 'strawberry'}, + {name: 'Vanilla', id: 'vanilla'}, + {name: 'Coffee', id: 'coffee'} +]; +export let Dynamic = { + render: (args: any) => { + if (args.onRemove) { + args.onRemove = action('remove'); + } + return ( +
+ + {(item: ITagItem) => {item.name}} + +
+ ); + }, + args: { + 'aria-label': 'Ice cream flavor', + errorMessage: 'You must love ice cream', + description: 'Pick a flavor' + } +}; + const SRC_URL_1 = 'https://mir-s3-cdn-cf.behance.net/project_modules/disp/690bc6105945313.5f84bfc9de488.png'; diff --git a/yarn.lock b/yarn.lock index 7b3e4c22218..b1eabe4ef64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7515,6 +7515,7 @@ __metadata: dependencies: "@adobe/spectrum-tokens": "npm:^13.0.0-beta.34" "@parcel/macros": "npm:2.12.1-canary.3165" + "@react-aria/collections": "npm:3.0.0-alpha.3" "@react-aria/i18n": "npm:^3.11.0" "@react-aria/interactions": "npm:^3.22.0" "@react-aria/utils": "npm:^3.23.0"