From 553ef949316f9fe061c714a09a3acce06fc8e183 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Fri, 30 Aug 2024 10:13:57 +1000 Subject: [PATCH] Breadcrumb collapse (#6892) feature - Breadcrumb collapse --- .../@react-spectrum/s2/src/Breadcrumbs.tsx | 377 +++++++++++++++--- packages/@react-spectrum/s2/src/Menu.tsx | 3 +- packages/@react-spectrum/s2/src/TagGroup.tsx | 1 - .../s2/stories/Breadcrumbs.stories.tsx | 65 ++- .../react-aria-components/src/Breadcrumbs.tsx | 7 +- packages/react-aria-components/src/Link.tsx | 2 +- .../stories/Breadcrumbs.stories.tsx | 20 + 7 files changed, 395 insertions(+), 80 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx index 4728d047926..001856a7ae7 100644 --- a/packages/@react-spectrum/s2/src/Breadcrumbs.tsx +++ b/packages/@react-spectrum/s2/src/Breadcrumbs.tsx @@ -10,17 +10,36 @@ * governing permissions and limitations under the License. */ -import {Breadcrumb as AriaBreadcrumb, BreadcrumbsProps as AriaBreadcrumbsProps, ContextValue, HeadingContext, Link, Provider, Breadcrumbs as RACBreadcrumbs, useSlottedContext} from 'react-aria-components'; +import { + Breadcrumb as AriaBreadcrumb, + BreadcrumbsProps as AriaBreadcrumbsProps, + CollectionRenderer, + ContextValue, + HeadingContext, + Link, + Provider, + Breadcrumbs as RACBreadcrumbs, + UNSTABLE_CollectionRendererContext, + UNSTABLE_DefaultCollectionRenderer +} from 'react-aria-components'; import {AriaBreadcrumbItemProps, useLocale} from 'react-aria'; import ChevronIcon from '../ui-icons/Chevron'; -import {createContext, forwardRef, ReactNode, useRef} from 'react'; -import {DOMRef, DOMRefValue, LinkDOMProps} from '@react-types/shared'; +import {Collection, DOMRef, DOMRefValue, LinkDOMProps, Node} from '@react-types/shared'; +import {createContext, forwardRef, Fragment, ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import FolderIcon from '../s2wf-icons/S2_Icon_FolderBreadcrumb_20_N.svg'; import {forwardRefType} from './types'; +import {Menu, MenuItem, MenuTrigger} from './Menu'; +import {ActionButton as S2ActionButton} from './ActionButton'; import {size, style} from '../style/spectrum-theme' with { type: 'macro' }; -import {useDOMRef} from '@react-spectrum/utils'; +import {Text} from './Content'; +import {useDOMRef, useResizeObserver} from '@react-spectrum/utils'; +import {useLayoutEffect} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; +const MIN_VISIBLE_ITEMS = 1; +const MAX_VISIBLE_ITEMS = 4; + interface BreadcrumbsStyleProps { /** * Size of the Breadcrumbs including spacing and layout. @@ -46,6 +65,7 @@ export interface BreadcrumbsProps extends Omit, 'chil export const BreadcrumbsContext = createContext, DOMRefValue>>(null); const wrapper = style({ + position: 'relative', display: 'flex', justifyContent: 'start', listStyleType: 'none', @@ -71,43 +91,121 @@ const wrapper = style({ } }, getAllowedOverrides()); +const InternalBreadcrumbsContext = createContext>({}); + function Breadcrumbs(props: BreadcrumbsProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, BreadcrumbsContext); + let domRef = useDOMRef(ref); let { UNSAFE_className = '', UNSAFE_style, styles, size = 'M', children, + isDisabled, ...otherProps } = props; - let domRef = useDOMRef(ref); + return ( - - - {children} - - + + + + {children} + + + ); } +let BreadcrumbMenu = (props: {items: Array>, onAction: BreadcrumbsProps['onAction']}) => { + let {items, onAction} = props; + let {direction} = useLocale(); + let {size, isDisabled} = useContext(InternalBreadcrumbsContext); + // TODO localize See more + return ( + +
  • + + + + {(item: Node) => ( + + + {item.props.children({size, isCurrent: false, isMenu: true})} + + + )} + + + +
  • +
    + ); +}; + /** Breadcrumbs show hierarchy and navigational context for a user’s location within an application. */ let _Breadcrumbs = /*#__PURE__*/ (forwardRef as forwardRefType)(Breadcrumbs); export {_Breadcrumbs as Breadcrumbs}; +let HiddenBreadcrumbs = function (props: {listRef: RefObject, items: Array>, size: string}) { + let {listRef, items, size} = props; + return ( +
    + {items.map((item, idx) => { + // pull off individual props as an allow list, don't want refs or other props getting through + return ( +
    + {item.props.children({size, isCurrent: idx === items.length - 1})} +
    + ); + })} + +
    + ); +}; + const breadcrumbStyles = style({ - display: 'inline-flex', + display: 'flex', alignItems: 'center', justifyContent: 'start', height: 'control', transition: 'default', position: 'relative', + flexShrink: 0, color: { default: 'neutral', isDisabled: 'disabled', @@ -116,7 +214,11 @@ const breadcrumbStyles = style({ isDisabled: 'GrayText' } }, - borderStyle: 'none' + borderStyle: 'none', + marginStart: { + // adjusts with the parent flex gap + isMenu: size(-6) + } }); const chevronStyles = style({ @@ -125,7 +227,16 @@ const chevronStyles = style({ rtl: -1 } }, - marginStart: 'text-to-visual', + marginStart: { + default: 'text-to-visual', + isMenu: 0 + }, + color: { + default: 'neutral', + forcedColors: { + default: 'LinkText' + } + }, '--iconPrimary': { type: 'fill', value: 'currentColor' @@ -135,6 +246,7 @@ const chevronStyles = style({ const linkStyles = style({ ...focusRing(), borderRadius: 'sm', + font: 'control', color: { default: 'neutral-subdued', isDisabled: 'disabled', @@ -145,11 +257,6 @@ const linkStyles = style({ } }, transition: 'default', - font: 'control', - fontWeight: { - default: 'normal', - isCurrent: 'bold' - }, textDecoration: { default: 'none', isHovered: 'underline', @@ -168,11 +275,6 @@ const linkStyles = style({ }); const currentStyles = style<{size: string}>({ - color: { - default: 'neutral', - forcedColors: 'ButtonText' - }, - transition: 'default', font: 'control', fontWeight: 'bold' }); @@ -189,47 +291,190 @@ export interface BreadcrumbProps extends Omit) { + let {href, target, rel, download, ping, referrerPolicy} = props; + let {size = 'M'} = useContext(InternalBreadcrumbsContext) ?? {}; + let domRef = useDOMRef(ref); let {direction} = useLocale(); return ( breadcrumbStyles({size, isCurrent})}> - {({isCurrent}) => ( - isCurrent ? - - - {children} - - - : ( - <> - ({clipPath: isFocusVisible ? 'none' : 'margin-box'})} - href={href} - target={target} - rel={rel} - download={download} - ping={ping} - referrerPolicy={referrerPolicy} - isDisabled={isDisabled || isCurrent} - className={({isFocused, isFocusVisible, isHovered, isDisabled, isPressed}) => linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isCurrent, isPressed})}> + {...props} + ref={domRef} + // @ts-ignore + originalProps={props} + className={({isCurrent, isDisabled}) => breadcrumbStyles({size, isCurrent, isDisabled})}> + {({ + isCurrent, + isDisabled, + // @ts-ignore + isMenu + }) => { + if (isMenu) { + return children; + } + return ( + isCurrent ? +
    + {children} - - - - ) - )} + +
    + : ( + <> + ({clipPath: isFocusVisible ? 'none' : 'margin-box'})} + href={href} + target={target} + rel={rel} + download={download} + ping={ping} + referrerPolicy={referrerPolicy} + isDisabled={isDisabled || isCurrent} + className={({isFocused, isFocusVisible, isHovered, isDisabled, isPressed}) => linkStyles({isFocused, isFocusVisible, isHovered, isDisabled, size, isPressed})}> + {children} + + + + ) + ); + }}
    ); } + +/** An individual Breadcrumb for Breadcrumbs. */ +let _Breadcrumb = /*#__PURE__*/ (forwardRef as forwardRefType)(Breadcrumb); +export {_Breadcrumb as Breadcrumb}; + +// Context for passing the count for the custom renderer +let CollapseContext = createContext<{ + containerRef: RefObject, + onAction: BreadcrumbsProps['onAction'] +} | null>(null); + +function CollapsingCollection({children, containerRef, onAction}) { + return ( + + + {children} + + + ); +} + +let CollapsingCollectionRenderer: CollectionRenderer = { + CollectionRoot({collection}) { + return useCollectionRender(collection); + }, + CollectionBranch({collection}) { + return useCollectionRender(collection); + } +}; + +let useCollectionRender = (collection: Collection>) => { + let {containerRef, onAction} = useContext(CollapseContext) ?? {}; + let [visibleItems, setVisibleItems] = useState(collection.size); + let {size = 'M'} = useContext(InternalBreadcrumbsContext); + + let children = useMemo(() => { + let result: Node[] = []; + for (let key of collection.getKeys()) { + result.push(collection.getItem(key)!); + } + return result; + }, [collection]); + + let listRef = useRef(null); + let updateOverflow = useCallback(() => { + let currListRef: HTMLDivElement | null = listRef.current; + if (!currListRef) { + setVisibleItems(collection.size); + return; + } + let container = currListRef.parentElement; + if (!container) { + setVisibleItems(collection.size); + return; + } + + let listItems = Array.from(currListRef.querySelectorAll('[data-hidden-breadcrumb]')) as HTMLLIElement[]; + let folder = currListRef.querySelector('button') as HTMLButtonElement; + if (listItems.length <= 0) { + setVisibleItems(collection.size); + return; + } + let containerWidth = container.offsetWidth; + let containerGap = parseInt(getComputedStyle(container).gap, 10); + let folderGap = parseInt(getComputedStyle(folder).marginInlineStart, 10); + let newVisibleItems = 0; + let maxVisibleItems = MAX_VISIBLE_ITEMS; + + let widths: Array = []; + let totalWidth = 0; + for (let breadcrumb of listItems) { + let width = breadcrumb.offsetWidth + 1; // offsetWidth is rounded down + widths.push(width); + totalWidth += width; + } + + // can we fit all the items without collapsing + if (totalWidth <= containerWidth - (collection.size * containerGap) && collection.size <= MAX_VISIBLE_ITEMS) { + setVisibleItems(collection.size); + return; + } + + // we know there is always at least one item because of the listItems.length check up above + let widthOfFirst = widths.shift()!; + let availableWidth = containerWidth - widthOfFirst - folderGap - folder.offsetWidth - containerGap; + maxVisibleItems -= 2; // account for the first item and folder + for (let width of widths.reverse()) { + availableWidth -= width; + if (availableWidth <= 0) { + break; + } + availableWidth -= containerGap; + newVisibleItems++; + } + + setVisibleItems(Math.max(MIN_VISIBLE_ITEMS, Math.min(maxVisibleItems, newVisibleItems))); + }, [collection.size, setVisibleItems]); + + // making bad assumption that i can listen to containerRef when it's declared in the parent? + useResizeObserver({ref: containerRef, onResize: updateOverflow}); + + useLayoutEffect(() => { + if (collection.size > 0) { + queueMicrotask(updateOverflow); + } + }, [collection.size, updateOverflow]); + + useEffect(() => { + // Recalculate visible tags when fonts are loaded. + document.fonts?.ready.then(() => updateOverflow()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + let sliceIndex = collection.size - visibleItems; + + return ( + <> + + {visibleItems < collection.size && collection.size > 2 ? ( + <> + {children[0].render?.(children[0])} + + {children.slice(sliceIndex).map(node => {node.render?.(node)})} + + ) : ( + <> + {children.map(node => {node.render?.(node)})} + + )} + + ); +}; diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 4f639785e11..63864e4e1fa 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -438,6 +438,7 @@ const linkIconSize = { export function MenuItem(props: MenuItemProps) { let ref = useRef(null); let isLink = props.href != null; + let isLinkOut = isLink && props.target === '_blank'; let {size} = useContext(InternalMenuContext); let textValue = props.textValue || (typeof props.children === 'string' ? props.children : undefined); let {direction} = useLocale(); @@ -478,7 +479,7 @@ export function MenuItem(props: MenuItemProps) { )} {typeof children === 'string' ? {children} : children} - {isLink && } + {isLinkOut && } {renderProps.hasSubmenu && (
    ({ })}> {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 (
    ( ); +let items = [ + {id: 'home', name: 'Home'}, + {id: 'react-aria', name: 'React Aria'}, + {id: 'breadcrumbs', name: 'Breadcrumbs'} +]; export const WithActions = (args: any) => ( - - - Home - - - React Aria - - - Breadcrumbs - + + {item => ( + + {item.name} + + )} ); + +let manyItems = [ + {id: 'Folder 1', name: 'The quick brown fox jumps over'}, + {id: 'Folder 2', name: 'My Documents'}, + {id: 'Folder 3', name: 'Kangaroos jump high'}, + {id: 'Folder 4', name: 'Koalas are very cute'}, + {id: 'Folder 5', name: "Wombat's noses"}, + {id: 'Folder 6', name: 'Wattle trees'}, + {id: 'Folder 7', name: 'April 7'} +]; + +export const Many = (args: any) => ( +
    + + {item => ( + + {item.name} + + )} + +
    +); + +let manyItemsWithLinks = [ + {id: 'Folder 1', name: 'The quick brown fox jumps over', href: '/folder1'}, + {id: 'Folder 2', name: 'My Documents', href: '/folder2'}, + {id: 'Folder 3', name: 'Kangaroos jump high', href: '/folder3'}, + {id: 'Folder 4', name: 'Koalas are very cute', href: '/folder4'}, + {id: 'Folder 5', name: "Wombat's noses", href: '/folder5'}, + {id: 'Folder 6', name: 'Wattle trees', href: '/folder6'}, + {id: 'Folder 7', name: 'April 7', href: '/folder7'} +]; + +export const ManyWithLinks = (args: any) => ( +
    + + {item => ( + + {item.name} + + )} + +
    +); diff --git a/packages/react-aria-components/src/Breadcrumbs.tsx b/packages/react-aria-components/src/Breadcrumbs.tsx index 8c87ecbb176..98c5e68439e 100644 --- a/packages/react-aria-components/src/Breadcrumbs.tsx +++ b/packages/react-aria-components/src/Breadcrumbs.tsx @@ -61,7 +61,12 @@ export interface BreadcrumbRenderProps { * Whether the breadcrumb is for the current page. * @selector [data-current] */ - isCurrent: boolean + isCurrent: boolean, + /** + * Whether the breadcrumb is disabled. + * @selector [data-current] + */ + isDisabled: boolean } export interface BreadcrumbProps extends RenderProps { diff --git a/packages/react-aria-components/src/Link.tsx b/packages/react-aria-components/src/Link.tsx index c104197cc9b..927ee3a187a 100644 --- a/packages/react-aria-components/src/Link.tsx +++ b/packages/react-aria-components/src/Link.tsx @@ -55,7 +55,7 @@ export const LinkContext = createContext) { [props, ref] = useContextProps(props, ref, LinkContext); - let ElementType: ElementType = props.href ? 'a' : 'span'; + let ElementType: ElementType = props.href && !props.isDisabled ? 'a' : 'span'; let {linkProps, isPressed} = useLink({...props, elementType: ElementType}, ref); let {hoverProps, isHovered} = useHover(props); diff --git a/packages/react-aria-components/stories/Breadcrumbs.stories.tsx b/packages/react-aria-components/stories/Breadcrumbs.stories.tsx index 7ecbe4135cc..0a1bdad5f93 100644 --- a/packages/react-aria-components/stories/Breadcrumbs.stories.tsx +++ b/packages/react-aria-components/stories/Breadcrumbs.stories.tsx @@ -31,3 +31,23 @@ export const BreadcrumbsExample = (args: any) => (
    ); + +interface Iitem { + id: string, + url: string +} +let items: Array = [ + {id: 'Home', url: '/'}, + {id: 'React Aria', url: '/react-aria'}, + {id: 'Breadcrumbs', url: '/react-aria/breadcrumbs'} +]; + +export const DynamicBreadcrumbsExample = (args: any) => ( + + {(item: Iitem) => ( + + {item.id} + + )} + +);