From 5cdc6753d4461b8e531870bcd9f4724dc6782d80 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Thu, 13 Jun 2024 14:04:22 +0200 Subject: [PATCH] feat(Breadcrumbs): new component (#1497) --- .../lab/Breadcrumbs/BreadcrumbItem.tsx | 96 ++++ .../lab/Breadcrumbs/Breadcrumbs.scss | 110 ++++ .../lab/Breadcrumbs/Breadcrumbs.tsx | 272 ++++++++++ .../lab/Breadcrumbs/BreadcrumbsSeparator.tsx | 16 + src/components/lab/Breadcrumbs/README.md | 501 ++++++++++++++++++ .../__stories__/Breadcrumbs.stories.tsx | 138 +++++ .../lab/Breadcrumbs/__stories__/Docs.mdx | 39 ++ .../__tests__/Breadcrumbs.test.tsx | 284 ++++++++++ src/components/lab/Breadcrumbs/i18n/en.json | 4 + src/components/lab/Breadcrumbs/i18n/index.ts | 8 + src/components/lab/Breadcrumbs/i18n/ru.json | 4 + src/components/lab/Breadcrumbs/index.ts | 1 + src/components/lab/Breadcrumbs/utils.ts | 24 + src/components/types.ts | 29 + src/components/utils/filterDOMProps.ts | 46 ++ src/hooks/index.ts | 1 + src/hooks/useResizeObserver/README.md | 18 + src/hooks/useResizeObserver/index.ts | 1 + .../useResizeObserver/useResizeObserver.ts | 34 ++ src/unstable.ts | 9 + 20 files changed, 1635 insertions(+) create mode 100644 src/components/lab/Breadcrumbs/BreadcrumbItem.tsx create mode 100644 src/components/lab/Breadcrumbs/Breadcrumbs.scss create mode 100644 src/components/lab/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx create mode 100644 src/components/lab/Breadcrumbs/README.md create mode 100644 src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx create mode 100644 src/components/lab/Breadcrumbs/__stories__/Docs.mdx create mode 100644 src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx create mode 100644 src/components/lab/Breadcrumbs/i18n/en.json create mode 100644 src/components/lab/Breadcrumbs/i18n/index.ts create mode 100644 src/components/lab/Breadcrumbs/i18n/ru.json create mode 100644 src/components/lab/Breadcrumbs/index.ts create mode 100644 src/components/lab/Breadcrumbs/utils.ts create mode 100644 src/components/utils/filterDOMProps.ts create mode 100644 src/hooks/useResizeObserver/README.md create mode 100644 src/hooks/useResizeObserver/index.ts create mode 100644 src/hooks/useResizeObserver/useResizeObserver.ts diff --git a/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx new file mode 100644 index 0000000000..e7c32c5f24 --- /dev/null +++ b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx @@ -0,0 +1,96 @@ +'use client'; + +import React from 'react'; + +import type {Href, RouterOptions} from '../../types'; +import {filterDOMProps} from '../../utils/filterDOMProps'; + +import type {BreadcrumbsItemProps} from './Breadcrumbs'; +import {b, shouldClientNavigate} from './utils'; + +interface BreadcrumbProps extends BreadcrumbsItemProps { + onAction?: () => void; + current?: boolean; + itemType?: 'link' | 'menu'; + disabled?: boolean; + navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; +} +export function BreadcrumbItem(props: BreadcrumbProps) { + const Element = props.href ? 'a' : 'span'; + const domProps = filterDOMProps(props, {labelable: true}); + + let title = props.title; + if (!title && typeof props.children === 'string') { + title = props.children; + } + + const handleAction = (event: React.MouseEvent | React.KeyboardEvent) => { + if (props.disabled || props.current) { + event.preventDefault(); + return; + } + + if (typeof props.onAction === 'function') { + props.onAction(); + } + + const target = event.currentTarget; + if (typeof props.navigate === 'function' && target instanceof HTMLAnchorElement) { + if (props.href && !event.isDefaultPrevented() && shouldClientNavigate(target, event)) { + event.preventDefault(); + props.navigate(props.href, props.routerOptions); + } + } + }; + + const isDisabled = props.disabled || props.current; + let linkProps: React.AnchorHTMLAttributes = { + title, + onClick: handleAction, + 'aria-disabled': isDisabled ? true : undefined, + }; + if (Element === 'a') { + linkProps.href = props.href; + linkProps.hrefLang = props.hrefLang; + linkProps.target = props.target; + linkProps.rel = props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel; + linkProps.download = props.download; + linkProps.ping = props.ping; + linkProps.referrerPolicy = props.referrerPolicy; + } else { + linkProps.role = 'link'; + linkProps.tabIndex = isDisabled ? undefined : 0; + linkProps.onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleAction(event); + } + }; + } + + if (props.current) { + linkProps['aria-current'] = 'page'; + } + + if (props.itemType === 'menu') { + linkProps = {}; + } + + return ( + + {props.children} + + ); +} + +BreadcrumbItem.displayName = 'Breadcrumbs.Item'; diff --git a/src/components/lab/Breadcrumbs/Breadcrumbs.scss b/src/components/lab/Breadcrumbs/Breadcrumbs.scss new file mode 100644 index 0000000000..11b32f5ed0 --- /dev/null +++ b/src/components/lab/Breadcrumbs/Breadcrumbs.scss @@ -0,0 +1,110 @@ +@use '../../variables'; +@use '../../../../styles/mixins'; + +$block: '.#{variables.$ns}breadcrumbs2'; + +#{$block} { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + + list-style-type: none; + margin: 0; + padding: 0; + + &__item { + position: relative; + display: flex; + align-items: center; + justify-content: flex-start; + + height: 24px; + white-space: nowrap; + color: var(--g-color-text-primary); + + &:last-child { + font-weight: var(--g-text-accent-font-weight); + overflow: hidden; + + #{$block}__link { + @include mixins.overflow-ellipsis(); + } + } + + &_calculating:last-child { + overflow: visible; + } + } + + &__link { + cursor: default; + position: relative; + text-decoration: none; + outline: none; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + + height: 24px; + line-height: 24px; + vertical-align: middle; + border-radius: var(--g-focus-border-radius); + + color: inherit; + + &_is-disabled { + color: var(--g-color-text-hint); + } + + &:not([aria-disabled]) { + cursor: pointer; + + &:hover { + color: var(--g-color-text-link-hover); + } + } + + &:focus-visible { + outline: 2px solid var(--g-color-line-focus); + } + } + + &__divider { + display: flex; + align-items: center; + color: var(--g-color-text-secondary); + padding: 0 var(--g-spacing-2); + } + + &__more-button { + --g-button-border-radius: var(--g-focus-border-radius); + --g-button-focus-outline-offset: -2px; + } + + &__menu { + margin-inline: calc(-1 * var(--g-spacing-2)); + } + + &__item:first-child &__menu { + margin-inline-start: 0; + } + + &__popup_staircase { + $menu: '.#{variables.$ns}menu'; + $staircaseLength: 10; + #{$menu} { + #{$menu}__list-item { + #{$menu}__item[class] { + padding-inline-start: 8px * $staircaseLength; + } + } + + @for $i from 0 through $staircaseLength { + #{$menu}__list-item:nth-child(#{$i}) { + #{$menu}__item[class] { + padding-inline-start: 8px * $i; + } + } + } + } + } +} diff --git a/src/components/lab/Breadcrumbs/Breadcrumbs.tsx b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..cc062d957c --- /dev/null +++ b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,272 @@ +'use client'; + +import React from 'react'; + +import {useForkRef, useResizeObserver} from '../../../hooks'; +import {Button} from '../../Button'; +import {DropdownMenu} from '../../DropdownMenu'; +import type {PopupPlacement} from '../../Popup'; +import type {AriaLabelingProps, DOMProps, Href, Key, QAProps, RouterOptions} from '../../types'; +import {filterDOMProps} from '../../utils/filterDOMProps'; + +import {BreadcrumbItem} from './BreadcrumbItem'; +import {BreadcrumbsSeparator} from './BreadcrumbsSeparator'; +import i18n from './i18n'; +import {b, shouldClientNavigate} from './utils'; + +import './Breadcrumbs.scss'; + +export interface BreadcrumbsItemProps { + children: React.ReactNode; + title?: string; + href?: Href; + hrefLang?: string; + target?: React.HTMLAttributeAnchorTarget; + rel?: string; + download?: boolean | string; + ping?: string; + referrerPolicy?: React.HTMLAttributeReferrerPolicy; + 'aria-label'?: string; + routerOptions?: RouterOptions; +} + +function Item(_props: BreadcrumbsItemProps): React.ReactNode { + return null; +} + +export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps { + id?: string; + showRoot?: boolean; + separator?: React.ReactNode; + maxItems?: number; + popupStyle?: 'staircase'; + popupPlacement?: PopupPlacement; + children: React.ReactElement | React.ReactElement[]; + navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; + disabled?: boolean; + onAction?: (key: Key) => void; +} + +export const Breadcrumbs = React.forwardRef(function Breadcrumbs( + props: BreadcrumbsProps, + ref: React.Ref, +) { + const listRef = React.useRef(null); + const containerRef = useForkRef(ref, listRef); + + const items: React.ReactElement[] = []; + React.Children.forEach(props.children, (child, index) => { + if (React.isValidElement(child)) { + if (child.key === undefined || child.key === null) { + child = React.cloneElement(child, {key: index}); + } + items.push(child); + } + }); + + const [visibleItemsCount, setVisibleItemsCount] = React.useState(items.length); + const [calculated, setCalculated] = React.useState(false); + const recalculate = (visibleItems: number) => { + const list = listRef.current; + if (!list) { + return; + } + const listItems = Array.from(list.children) as HTMLElement[]; + if (listItems.length === 0) { + return; + } + const containerWidth = list.offsetWidth; + let newVisibleItemsCount = 0; + let calculatedWidth = 0; + let maxItems = props.maxItems || Infinity; + + let rootWidth = 0; + if (props.showRoot) { + const item = listItems.shift(); + if (item) { + rootWidth = item.scrollWidth; + calculatedWidth += rootWidth; + } + newVisibleItemsCount++; + } + + const hasMenu = items.length > visibleItems; + if (hasMenu) { + const item = listItems.shift(); + if (item) { + calculatedWidth += item.offsetWidth; + } + maxItems--; + } + + if (props.showRoot && calculatedWidth >= containerWidth) { + calculatedWidth -= rootWidth; + newVisibleItemsCount--; + } + + const lastItem = listItems.pop(); + if (lastItem) { + calculatedWidth += Math.min(lastItem.offsetWidth, 200); + if (calculatedWidth < containerWidth) { + newVisibleItemsCount++; + } + } + + for (let i = listItems.length - 1; i >= 0; i--) { + const item = listItems[i]; + calculatedWidth += item.offsetWidth; + if (calculatedWidth >= containerWidth) { + break; + } + newVisibleItemsCount++; + } + + newVisibleItemsCount = Math.max(Math.min(maxItems, newVisibleItemsCount), 1); + if (newVisibleItemsCount === visibleItemsCount) { + setCalculated(true); + } else { + setVisibleItemsCount(newVisibleItemsCount); + } + }; + + const handleResize = React.useCallback(() => { + setCalculated(false); + setVisibleItemsCount(items.length); + }, [items.length]); + useResizeObserver({ + ref: listRef, + onResize: handleResize, + }); + + const lastChildren = React.useRef(null); + React.useLayoutEffect(() => { + if (calculated && props.children !== lastChildren.current) { + lastChildren.current = props.children; + setCalculated(false); + setVisibleItemsCount(items.length); + } + }, [calculated, items.length, props.children]); + + React.useLayoutEffect(() => { + if (!calculated) { + recalculate(visibleItemsCount); + } + }); + + const {navigate} = props; + let contents = items; + if (items.length > visibleItemsCount) { + contents = []; + const breadcrumbs = [...items]; + let endItems = visibleItemsCount; + if (props.showRoot && visibleItemsCount > 1) { + const rootItem = breadcrumbs.shift(); + if (rootItem) { + contents.push(rootItem); + } + endItems--; + } + const hiddenItems = breadcrumbs.slice(0, -endItems); + const menuItem = ( + + { + return { + ...el.props, + text: el.props.children, + disabled: props.disabled, + items: [], + action: (event) => { + if (typeof props.onAction === 'function') { + props.onAction(el.key ?? index); + } + + // TODO: move this logic to DropdownMenu + const target = event.currentTarget; + if ( + typeof navigate === 'function' && + target instanceof HTMLAnchorElement + ) { + if (el.props.href && shouldClientNavigate(target, event)) { + event.preventDefault(); + navigate(el.props.href, el.props.routerOptions); + } + } + }, + }; + })} + popupProps={{ + className: b('popup', { + staircase: props.popupStyle === 'staircase', + }), + placement: props.popupPlacement, + }} + renderSwitcher={({onClick}) => ( + + )} + /> + + ); + + contents.push(menuItem); + contents.push(...breadcrumbs.slice(-endItems)); + } + + const lastIndex = contents.length - 1; + const breadcrumbItems = contents.map((child, index) => { + const isCurrent = index === lastIndex; + const key = child.key ?? index; + const handleAction = () => { + if (typeof props.onAction === 'function') { + props.onAction(key); + } + }; + + return ( +
  • + + {child.props.children} + + {isCurrent ? null : } +
  • + ); + }); + return ( +
      + {breadcrumbItems} +
    + ); +}) as unknown as BreadcrumbsComponent; + +type BreadcrumbsComponent = React.FunctionComponent< + BreadcrumbsProps & {ref?: React.Ref} +> & { + Item: typeof Item; +}; + +Breadcrumbs.Item = Item; +Breadcrumbs.displayName = 'Breadcrumbs'; + +export {Item as BreadcrumbsItem}; diff --git a/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx b/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx new file mode 100644 index 0000000000..299f19fd4e --- /dev/null +++ b/src/components/lab/Breadcrumbs/BreadcrumbsSeparator.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type {BreadcrumbsProps} from './Breadcrumbs'; +import {b} from './utils'; + +type Props = Pick; + +export function BreadcrumbsSeparator({separator}: Props) { + return ( +
    + {separator ?? '/'} +
    + ); +} + +BreadcrumbsSeparator.displayName = 'Breadcrumbs.Separator'; diff --git a/src/components/lab/Breadcrumbs/README.md b/src/components/lab/Breadcrumbs/README.md new file mode 100644 index 0000000000..b3e3683234 --- /dev/null +++ b/src/components/lab/Breadcrumbs/README.md @@ -0,0 +1,501 @@ + + +# Breadcrumbs + + + +```tsx +import {unstable_Breadcrumbs as Breadcrumbs} from '@gravity-ui/uikit/unstable'; +``` + +`Breadcrumbs` is a navigation element that shows the current location of a page within a website’s hierarchy. It provides links that allow users to return to higher levels in the hierarchy, making it easier to navigate a site with multiple layers. Breadcrumbs are especially useful for large websites and applications with a hierarchical organization of pages. + +## Example + + + + + +```jsx + + Region + Country + City + District + Street + +``` + + + + + + + +### Events + +Use the `onAction` prop as a callback to handle click events on items. + + + + + +```jsx +const [currentId, setCurrentId] = React.useState(); +const items = [ + {id: 1, label: 'Region'}, + {id: 2, label: 'Country'}, + {id: 3, label: 'City'}, + {id: 4, label: 'District'}, + {id: 5, label: 'Street'}, +] +
    + + {items.map((i) => {i.label})} + +

    You clicked item ID: {currentId}

    +
    +``` + + + + + + + +### Links + +In Breadcrumbs, clicking an item normally triggers `onAction`. But you can also make them links to other pages or websites. To do that, add the href property to the `` component. + + + + + +```jsx + + Home + Components + Breadcrumbs + +``` + + + + + + + +### Root context + +To help users understand the overall structure, some applications always show the starting point (root item) of the Breadcrumbs, even when other items are hidden due to space limitations. + + + + + +```jsx + + + Home + Trendy + March 2020 Assets + Winter + Holiday + + +``` + + + + + + + +### Separator + + + + + +```jsx + + {breadcrumbs} + + + {breadcrumbs} + +}> + {breadcrumbs} + +``` + + + + + + + +### Breadcrumbs with icons + + + + + +```jsx + + + + uikit + + + + + components + + + + + + + Breadcrumbs + + + + +``` + + + + + + + +### Integration with routers + +`Breadcrumbs` component accepts navigate function received from your router for performing a client side navigation programmatically. +The following example shows the general pattern. + +```jsx +function Header() { + const navigate = useNavigateFromYourRouter(); + + return ( +
    + {/*...*/} +
    + ); +} +``` + +#### React Router v5 + +```jsx +import {useHistory} from 'react-router-dom'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const history = useHistory(); + + return ( +
    + {/*...*/} +
    + ); +} +``` + +#### React Router v6 + +```jsx +import {useNavigate} from 'react-router-dom'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const navigate = useNavigate(); + + return ( +
    + {/*...*/} +
    + ); +} +``` + +#### Next.js + +`App router` + +```jsx +'use client'; + +import {useRouter} from 'next/navigation'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const router = useRouter(); + + return ( +
    + {/*...*/} +
    + ); +} +``` + +`Pages router` + +```jsx +import {useRouter} from 'next/router'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const router = useRouter(); + + return ( +
    + {/*...*/} +
    + ); +} +``` + +### Landmarks + +When breadcrumbs are used as a main navigation element for a page, they can be placed in a [navigation landmark](https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/examples/navigation.html). Landmarks help assistive technology users quickly find major sections of a page. Place breadcrumbs inside a `