From 5cdc6753d4461b8e531870bcd9f4724dc6782d80 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko <balepas@nebius.com> 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<HTMLElement> = { + 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 ( + <Element + {...domProps} + {...linkProps} + className={ + props.itemType === 'menu' + ? b('menu') + : b('link', { + 'is-current': props.current, + 'is-disabled': isDisabled && !props.current, + }) + } + > + {props.children} + </Element> + ); +} + +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<BreadcrumbsItemProps> | React.ReactElement<BreadcrumbsItemProps>[]; + 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<HTMLOListElement>, +) { + const listRef = React.useRef<HTMLOListElement>(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<typeof props.children | null>(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 = ( + <BreadcrumbItem itemType="menu"> + <DropdownMenu + items={hiddenItems.map((el, index) => { + 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}) => ( + <Button + title={i18n('label_more')} + className={b('more-button')} + onClick={onClick} + size="s" + view="flat" + disabled={props.disabled} + > + <Button.Icon>...</Button.Icon> + </Button> + )} + /> + </BreadcrumbItem> + ); + + 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 ( + <li key={index} className={b('item', {calculating: !calculated})}> + <BreadcrumbItem + {...child.props} + key={key} + current={isCurrent} + disabled={props.disabled} + onAction={handleAction} + navigate={navigate} + > + {child.props.children} + </BreadcrumbItem> + {isCurrent ? null : <BreadcrumbsSeparator separator={props.separator} />} + </li> + ); + }); + return ( + <ol + ref={containerRef} + {...filterDOMProps(props, {labelable: true})} + data-qa={props.qa} + className={b(null, props.className)} + style={props.style} + > + {breadcrumbItems} + </ol> + ); +}) as unknown as BreadcrumbsComponent; + +type BreadcrumbsComponent = React.FunctionComponent< + BreadcrumbsProps & {ref?: React.Ref<HTMLElement>} +> & { + 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<BreadcrumbsProps, 'separator'>; + +export function BreadcrumbsSeparator({separator}: Props) { + return ( + <div aria-hidden={true} className={b('divider')}> + {separator ?? '/'} + </div> + ); +} + +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 @@ +<!--GITHUB_BLOCK--> + +# Breadcrumbs + +<!--/GITHUB_BLOCK--> + +```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 + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Breadcrumbs> + <Breadcrumbs.Item>Region</Breadcrumbs.Item> + <Breadcrumbs.Item>Country</Breadcrumbs.Item> + <Breadcrumbs.Item>City</Breadcrumbs.Item> + <Breadcrumbs.Item>District</Breadcrumbs.Item> + <Breadcrumbs.Item>Street</Breadcrumbs.Item> +</Breadcrumbs> +`} +> + <UIKit.Breadcrumbs> + <UIKit.Breadcrumbs.Item>Region</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>Country</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>City</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>District</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>Street</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<Breadcrumbs> + <Breadcrumbs.Item>Region</Breadcrumbs.Item> + <Breadcrumbs.Item>Country</Breadcrumbs.Item> + <Breadcrumbs.Item>City</Breadcrumbs.Item> + <Breadcrumbs.Item>District</Breadcrumbs.Item> + <Breadcrumbs.Item>Street</Breadcrumbs.Item> +</Breadcrumbs> +``` + +<!-- Storybook example --> + +<BreadcrumbsExample /> + +<!--/GITHUB_BLOCK--> + +### Events + +Use the `onAction` prop as a callback to handle click events on items. + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Breadcrumbs onAction={(id) => alert(id)}> + <Breadcrumbs.Item key={1}>Region</Breadcrumbs.Item> + <Breadcrumbs.Item key={2}>Country</Breadcrumbs.Item> + <Breadcrumbs.Item key={3}>City</Breadcrumbs.Item> + <Breadcrumbs.Item key={4}>District</Breadcrumbs.Item> + <Breadcrumbs.Item key={5}>Street</Breadcrumbs.Item> +</Breadcrumbs> +`} +> + <UIKit.Breadcrumbs onAction={(id) => alert(id)}> + <UIKit.Breadcrumbs.Item key={1}>Region</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key={2}>Country</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key={3}>City</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key={4}>District</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key={5}>Street</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```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'}, +] +<div> + <Breadcrumbs onAction={setCurrentId}> + {items.map((i) => <Breadcrumbs.Item key={i.id}>{i.label}</Breadcrumbs.Item>)} + </Breadcrumbs> + <p>You clicked item ID: {currentId}</p> +</div> +``` + +<!-- Storybook example --> + +<BreadcrumbsEvents /> + +<!--/GITHUB_BLOCK--> + +### 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 `<Breadcrumbs.Item>` component. + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Breadcrumbs> + <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item> +</Breadcrumbs> +`} +> + <UIKit.Breadcrumbs> + <UIKit.Breadcrumbs.Item href="/">Home</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item href="/components">Components</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<Breadcrumbs> + <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item> +</Breadcrumbs> +``` + +<!-- Storybook example --> + +<BreadcrumbsLinks /> + +<!--/GITHUB_BLOCK--> + +### 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. + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Box overflow="hidden" width={200}> + <Breadcrumbs showRoot> + <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item> + <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item> + <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item> + <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item> + <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item> + </Breadcrumbs> +</Box> +`} +> +<UIKit.Box overflow="hidden" width={200}> + <UIKit.Breadcrumbs> + <UIKit.Breadcrumbs.Item key="home">Home</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key="trendy">Trendy</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key="2020 assets">March 2020 Assets</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key="winter">Winter</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item key="holiday">Holiday</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</UIKit.Box> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<Box overflow="hidden" width={200}> + <Breadcrumbs showRoot> + <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item> + <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item> + <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item> + <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item> + <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item> + </Breadcrumbs> +</Box> +``` + +<!-- Storybook example --> + +<BreadcrumbsRootContext /> + +<!--/GITHUB_BLOCK--> + +### Separator + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Breadcrumbs separator=">"> + <Breadcrumbs.Item>Region</Breadcrumbs.Item> + <Breadcrumbs.Item>Country</Breadcrumbs.Item> + <Breadcrumbs.Item>City</Breadcrumbs.Item> + <Breadcrumbs.Item>District</Breadcrumbs.Item> + <Breadcrumbs.Item>Street</Breadcrumbs.Item> +</Breadcrumbs> +`} +> + <UIKit.Breadcrumbs separator=">"> + <UIKit.Breadcrumbs.Item>Region</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>Country</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>City</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>District</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item>Street</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<Breadcrumbs separator="›"> + {breadcrumbs} +</Breadcrumbs> +<Breadcrumbs separator="—"> + {breadcrumbs} +</Breadcrumbs> +<Breadcrumbs separator={<ChevronRight />}> + {breadcrumbs} +</Breadcrumbs> +``` + +<!-- Storybook example --> + +<BreadcrumbsSeparator /> + +<!--/GITHUB_BLOCK--> + +### Breadcrumbs with icons + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<Breadcrumbs> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <House /> uikit + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Flame /> components + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Rocket style={{minWidth: 16}} /> + <Text ellipsis variant="inherit"> + Breadcrumbs + </Text> + </Flex> + </Breadcrumbs.Item> +</Breadcrumbs> +`} +> + <UIKit.Breadcrumbs> + <UIKit.Breadcrumbs.Item> + <UIKit.Flex alignItems="center" gap={1}> + <UIKit.Icon data={() => ( + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M12.5 12.618c.307-.275.5-.674.5-1.118V6.977a1.5 1.5 0 0 0-.585-1.189l-3.5-2.692a1.5 1.5 0 0 0-1.83 0l-3.5 2.692A1.5 1.5 0 0 0 3 6.978V11.5A1.496 1.496 0 0 0 4.493 13H5V9.5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2V13h.507c.381-.002.73-.146.993-.382Zm2-1.118a3 3 0 0 1-3 3h-7a3 3 0 0 1-3-3V6.977A3 3 0 0 1 2.67 4.6l3.5-2.692a3 3 0 0 1 3.66 0l3.5 2.692a3.003 3.003 0 0 1 1.17 2.378V11.5Zm-5-2A.5.5 0 0 0 9 9H7a.5.5 0 0 0-.5.5V13h3V9.5Z" clip-rule="evenodd"/></svg> + )} /> uikit + </UIKit.Flex> + </UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item> + <UIKit.Flex alignItems="center" gap={1}> + <UIKit.Icon data={() => ( + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fillRule="evenodd" d="m6.452 6.864 1.13-2.173a31.715 31.715 0 0 1 1.872-3.095c.964 1.045 1.906 2.3 2.612 3.622.748 1.402 1.184 2.789 1.184 4.032 0 1.427-.904 2.83-2.153 3.613.058-.265.09-.553.09-.863 0-1.255-.674-2.336-1.143-2.963a8.82 8.82 0 0 0-1.01-1.125l-.024-.02-.008-.008L9 7.88l-.001-.001C8.996 7.88 8.996 7.878 8 9a7.03 7.03 0 0 0 .984 1.133c.37.534.704 1.2.704 1.867 0 .77-.313 1.276-.618 1.587-.159.162-.279.38-.314.6a.786.786 0 0 0 0 .264.694.694 0 0 0 .06.182c.113.225.343.37.594.35 2.836-.235 5.34-2.87 5.34-5.733 0-3.149-2.177-6.538-4.357-8.845A1.313 1.313 0 0 0 9.435 0 1.32 1.32 0 0 0 8.35.556 33.486 33.486 0 0 0 6.25 4l-.955-1.337a.986.986 0 0 0-1.589-.018C2.62 4.123 1.25 6.249 1.25 9.25c0 2.863 2.504 5.498 5.34 5.733.25.02.481-.125.593-.35a.672.672 0 0 0 .06-.182.786.786 0 0 0 .001-.263 1.145 1.145 0 0 0-.314-.601c-.305-.31-.617-.817-.617-1.587 0-.666.333-1.333.703-1.867l.09-.128C7.544 9.405 8 9 8 9l-.997-1.12H7l-.003.003-.008.007-.024.021-.073.07a8.827 8.827 0 0 0-.937 1.056c-.47.626-1.143 1.707-1.143 2.962 0 .31.033.598.09.863C3.654 12.08 2.75 10.677 2.75 9.25c0-2.171.847-3.812 1.745-5.126l.534.748 1.423 1.992ZM8 9l.997-1.121L8 6.993l-.997.886L8 9Z" clipRule="evenodd" /></svg> + )} /> components + </UIKit.Flex> + </UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item> + <UIKit.Flex alignItems="center" gap={1}> + <UIKit.Icon data={() => ( + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill="currentColor" fill-rule="evenodd" d="M15.37 1.268a.75.75 0 0 0-.62-.634 7.745 7.745 0 0 0-7.516 3.055l-.156.212-2.59.112a.75.75 0 0 0-.433.16L.696 6.827a.75.75 0 0 0 .206 1.292L4.25 9.352c.673.273 1.13.56 1.484.913.352.353.64.811.913 1.484l1.234 3.35a.75.75 0 0 0 1.292.205l2.652-3.36a.75.75 0 0 0 .16-.431l.113-2.591.212-.156a7.745 7.745 0 0 0 3.058-7.498ZM4.794 5.501l1.144-.05-1.69 2.302-1.572-.58 2.118-1.672Zm4.032 7.822-.58-1.572 2.302-1.69-.05 1.145-1.672 2.117Zm5.127-11.277a6.246 6.246 0 0 0-5.511 2.531l-2.78 3.786c.425.237.8.51 1.132.842.332.332.606.707.842 1.133l3.786-2.78a6.246 6.246 0 0 0 2.53-5.512ZM2.378 13.952a5.36 5.36 0 0 1-.377.053 5.52 5.52 0 0 1 .05-.366c.104-.59.294-1.014.527-1.247.244-.244.694-.274 1.004.036.31.31.281.76.036 1.005-.223.223-.644.413-1.24.519ZM.48 15.069a7.796 7.796 0 0 1 .025-1.18c.082-.838.33-1.876 1.012-2.557.853-.854 2.253-.838 3.126.035.873.874.89 2.273.036 3.126-1.082 1.082-3.112 1.068-3.735 1.036a.487.487 0 0 1-.319-.145.486.486 0 0 1-.145-.316Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="currentColor" d="M0 0h16v16H0z"/></clipPath></defs></svg> + )} /> + <UIKit.Text varian="inherit" ellipsis> + Breadcrumbs + </UIKit.Text> + </UIKit.Flex> + </UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<Breadcrumbs> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <House /> uikit + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Flame /> components + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Rocket style={{minWidth: 16}} /> + <Text ellipsis variant="inherit"> + Breadcrumbs + </Text> + </Flex> + </Breadcrumbs.Item> +</Breadcrumbs> +``` + +<!-- Storybook example --> + +<BreadcrumbsWithIcons /> + +<!--/GITHUB_BLOCK--> + +### 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 ( + <header> + <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs> + </header> + ); +} +``` + +#### React Router v5 + +```jsx +import {useHistory} from 'react-router-dom'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const history = useHistory(); + + return ( + <header> + <Breadcrumbs navigate={history.push}>{/*...*/}</Breadcrumbs> + </header> + ); +} +``` + +#### React Router v6 + +```jsx +import {useNavigate} from 'react-router-dom'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const navigate = useNavigate(); + + return ( + <header> + <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs> + </header> + ); +} +``` + +#### Next.js + +`App router` + +```jsx +'use client'; + +import {useRouter} from 'next/navigation'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const router = useRouter(); + + return ( + <header> + <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs> + </header> + ); +} +``` + +`Pages router` + +```jsx +import {useRouter} from 'next/router'; +import {Breadcrumbs} from '@gravity-ui/uikit'; + +function Header() { + const router = useRouter(); + + return ( + <header> + <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs> + </header> + ); +} +``` + +### 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 `<nav>` element with an aria-label to create a navigation landmark. + +<!--LANDING_BLOCK + +<ExampleBlock + code={` +<nav aria-label="Breadcrumbs"> + <Breadcrumbs> + <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item> + </Breadcrumbs> +</nav> +`} +> + <nav aria-label="Breadcrumbs"> + <UIKit.Breadcrumbs> + <UIKit.Breadcrumbs.Item href="/">Home</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item href="/components">Components</UIKit.Breadcrumbs.Item> + <UIKit.Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</UIKit.Breadcrumbs.Item> + </UIKit.Breadcrumbs> + </nav> +</ExampleBlock> + +LANDING_BLOCK--> + +<!--GITHUB_BLOCK--> + +```jsx +<nav aria-label="Breadcrumbs"> + <Breadcrumbs> + <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components">Components</Breadcrumbs.Item> + <Breadcrumbs.Item href="/components/uikit/breadcrumbs">Breadcrumbs</Breadcrumbs.Item> + </Breadcrumbs> +</nav> +``` + +<!-- Storybook example --> + +<BreadcrumbsLinks /> + +<!--/GITHUB_BLOCK--> + +## Properties + +| Name | Description | Type | Default | +| :--------------- | :-------------------------------------------------------------------- | :----------------------------------------- | :------ | +| children | Breadcrumb items. | `React.ReactElement<BreadcrumbsItemProps>` | | +| disabled | Whether the Breadcrumbs are disabled. | `boolean` | | +| showRoot | Whether to always show the root item if the items are collapsed. | `boolean` | | +| popupPlacement | Style of collapsed item popup. | `PopupPlacement` | | +| popupStyle | Style of collapsed item popup. | `"staircase"` | | +| qa | HTML `data-qa` attribute, used in tests. | `string` | | +| separator | Custom separator node. | `React.ReactNode` | "/" | +| action | `click` event handler. | `(id: Key) => void` | | +| navigate | client side navigation. | `(href: string) => void` | | +| id | The element's unique identifier. | `string` | | +| className | CSS class name for the element. | `string` | | +| style | Sets inline style for the element. | `CSSProperties` | | +| aria-label | Defines a string value that labels the current element. | `string` | | +| aria-labelledby | Identifies the element (or elements) that labels the current element. | `string` | | +| aria-describedby | Identifies the element (or elements) that describes the object. | `string` | | + +### BreadcrumbsItemProps + +| Name | Description | Type | Default | +| :--------- | :----------------------------------------------------------------- | :-------------------------------- | :------ | +| children | Breadcrumb content. | `string` | | +| title | A string representation of the item's contents. | `string` | | +| aria-label | An accessibility label for this item. | `string` | | +| href | A URL to link to. | `string` | | +| target | The target window for the link. | `React.HTMLAttributeAnchorTarget` | | +| rel | The relationship between the linked resource and the current page. | `string` | | diff --git a/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx b/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx new file mode 100644 index 0000000000..59b90c69c3 --- /dev/null +++ b/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx @@ -0,0 +1,138 @@ +import React from 'react'; + +import {ChevronRight, Flame, House, Rocket} from '@gravity-ui/icons'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Text} from '../../../Text'; +import {Box, Flex} from '../../../layout'; +import type {Key} from '../../../types'; +import {Breadcrumbs} from '../Breadcrumbs'; + +const meta: Meta<typeof Breadcrumbs> = { + title: 'Lab/Breadcrumbs', + component: Breadcrumbs, +}; + +export default meta; + +type Story = StoryObj<typeof Breadcrumbs>; + +export const Default = { + render: (args) => ( + <Breadcrumbs {...args}> + <Breadcrumbs.Item>Region</Breadcrumbs.Item> + <Breadcrumbs.Item>Country</Breadcrumbs.Item> + <Breadcrumbs.Item>City</Breadcrumbs.Item> + <Breadcrumbs.Item>District</Breadcrumbs.Item> + <Breadcrumbs.Item>Street</Breadcrumbs.Item> + </Breadcrumbs> + ), +} satisfies Story; + +export const Events = { + render: function BreadcrumbsEvents(props) { + const [currentId, setCurrentId] = React.useState<Key>(); + const items = [ + {id: 1, label: 'Region'}, + {id: 2, label: 'Country'}, + {id: 3, label: 'City'}, + {id: 4, label: 'District'}, + {id: 5, label: 'Street'}, + ]; + return ( + <div> + <Breadcrumbs {...props} onAction={setCurrentId}> + {items.map((i) => ( + <Breadcrumbs.Item key={i.id}>{i.label}</Breadcrumbs.Item> + ))} + </Breadcrumbs> + <p>You clicked item ID: {currentId}</p> + </div> + ); + }, +} satisfies Story; + +export const Links = { + render: (args) => ( + <Breadcrumbs {...args}> + <Breadcrumbs.Item href="https://gravity-ui.com" target="_blank"> + Home + </Breadcrumbs.Item> + <Breadcrumbs.Item href="https://gravity-ui.com/components" target="_blank"> + Components + </Breadcrumbs.Item> + <Breadcrumbs.Item + href="https://gravity-ui.com/components/uikit/breadcrumbs" + target="_blank" + > + Breadcrumbs + </Breadcrumbs.Item> + </Breadcrumbs> + ), +} satisfies Story; + +export const RootContext = { + render: (args) => ( + <Box overflow="hidden" width={200} style={{padding: 2}}> + <Breadcrumbs {...args} showRoot> + <Breadcrumbs.Item key="home">Home</Breadcrumbs.Item> + <Breadcrumbs.Item key="trendy">Trendy</Breadcrumbs.Item> + <Breadcrumbs.Item key="2020 assets">March 2020 Assets</Breadcrumbs.Item> + <Breadcrumbs.Item key="winter">Winter</Breadcrumbs.Item> + <Breadcrumbs.Item key="holiday">Holiday</Breadcrumbs.Item> + </Breadcrumbs> + </Box> + ), +} satisfies Story; + +export const Separator = { + render: (args) => { + const breadcrumbs = [ + <Breadcrumbs.Item key={1}>uikit</Breadcrumbs.Item>, + <Breadcrumbs.Item key={2}>components</Breadcrumbs.Item>, + <Breadcrumbs.Item key={3}>Breadcrumbs</Breadcrumbs.Item>, + ]; + return ( + <div> + <Breadcrumbs {...args} separator="›"> + {breadcrumbs} + </Breadcrumbs> + <Breadcrumbs {...args} separator="—"> + {breadcrumbs} + </Breadcrumbs> + <Breadcrumbs {...args} separator={<ChevronRight />}> + {breadcrumbs} + </Breadcrumbs> + </div> + ); + }, +} satisfies Story; + +export const WithIcons = { + render: (args) => ( + <Breadcrumbs {...args}> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <House /> uikit + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Flame /> components + </Flex> + </Breadcrumbs.Item> + <Breadcrumbs.Item> + <Flex alignItems="center" gap={1}> + <Rocket style={{minWidth: 16}} /> + <Text ellipsis variant="inherit"> + Breadcrumbs + </Text> + </Flex> + </Breadcrumbs.Item> + </Breadcrumbs> + ), +} satisfies Story; + +export const Landmarks = { + render: (args) => <nav aria-label="Breadcrumb">{Links.render(args)}</nav>, +} satisfies Story; diff --git a/src/components/lab/Breadcrumbs/__stories__/Docs.mdx b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx new file mode 100644 index 0000000000..12225aef2b --- /dev/null +++ b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx @@ -0,0 +1,39 @@ +import { + Meta, + Markdown, + Canvas, + AnchorMdx, + CodeOrSourceMdx, + HeadersMdx, +} from '@storybook/addon-docs'; +import * as Stories from './Breadcrumbs.stories'; +import Readme from '../README.md?raw'; + +export const BreadcrumbsExample = () => <Canvas of={Stories.Default} sourceState="none" />; +export const BreadcrumbsEvents = () => <Canvas of={Stories.Events} sourceState="none" />; +export const BreadcrumbsLinks = () => <Canvas of={Stories.Links} sourceState="none" />; +export const BreadcrumbsRootContext = () => <Canvas of={Stories.RootContext} sourceState="none" />; +export const BreadcrumbsSeparator = () => <Canvas of={Stories.Separator} sourceState="none" />; +export const BreadcrumbsWithIcons = () => <Canvas of={Stories.WithIcons} sourceState="none" />; +export const BreadcrumbsLandmarks = () => <Canvas of={Stories.Landmarks} sourceState="none" />; + +<Meta of={Stories} /> + +<Markdown + options={{ + overrides: { + code: CodeOrSourceMdx, + a: AnchorMdx, + ...HeadersMdx, + BreadcrumbsExample, + BreadcrumbsEvents, + BreadcrumbsLinks, + BreadcrumbsRootContext, + BreadcrumbsSeparator, + BreadcrumbsWithIcons, + BreadcrumbsLandmarks, + }, + }} +> + {Readme} +</Markdown> diff --git a/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx new file mode 100644 index 0000000000..f44ec07f73 --- /dev/null +++ b/src/components/lab/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; + +import {userEvent} from '@testing-library/user-event'; + +import {render, screen, within} from '../../../../../test-utils/utils'; +import {Breadcrumbs} from '../Breadcrumbs'; + +beforeEach(() => { + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + this: Element, + ) { + if (this instanceof HTMLOListElement) { + return 500; + } + return 100; + }); +}); + +it('handles multiple items', () => { + render( + <Breadcrumbs> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const item1 = screen.getByText('Folder 1'); + expect(item1.tabIndex).toBe(0); + expect(item1).not.toHaveAttribute('aria-current'); + const item2 = screen.getByText('Folder 2'); + expect(item2.tabIndex).toBe(0); + expect(item2).not.toHaveAttribute('aria-current'); + const item3 = screen.getByText('Folder 3'); + expect(item3.tabIndex).toBe(-1); + expect(item3).toHaveAttribute('aria-current', 'page'); +}); + +it('should handle forward ref', function () { + let ref: React.RefObject<any> | undefined; + const Component = () => { + ref = React.useRef(); + return ( + <Breadcrumbs ref={ref} aria-label="breadcrumbs-test"> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + </Breadcrumbs> + ); + }; + render(<Component />); + const breadcrumb = screen.getByLabelText('breadcrumbs-test'); + expect(breadcrumb).toBe(ref?.current); +}); + +it('shows four items with no menu', () => { + render( + <Breadcrumbs maxItems={4}> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const {children} = screen.getByRole('list'); + expect(within(children[0] as HTMLElement).queryByRole('button')).toBeNull(); + expect(screen.getByText('Folder 1')).toBeTruthy(); + expect(screen.getByText('Folder 2')).toBeTruthy(); + expect(screen.getByText('Folder 3')).toBeTruthy(); + expect(screen.getByText('Folder 4')).toBeTruthy(); +}); + +it('shows a maximum of 3 items', () => { + render( + <Breadcrumbs maxItems={3}> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const {children} = screen.getByRole('list'); + expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy(); + expect(() => screen.getByText('Folder 1')).toThrow(); + expect(() => screen.getByText('Folder 2')).toThrow(); + expect(screen.getByText('Folder 3')).toBeTruthy(); + expect(screen.getByText('Folder 4')).toBeTruthy(); +}); + +it('shows a maximum of 3 items with showRoot', () => { + render( + <Breadcrumbs maxItems={3} showRoot> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const {children} = screen.getByRole('list'); + expect(screen.getByText('Folder 1')).toBeTruthy(); + expect(within(children[1] as HTMLElement).getByRole('button')).toBeTruthy(); + expect(() => screen.getByText('Folder 2')).toThrow(); + expect(() => screen.getByText('Folder 3')).toThrow(); + expect(screen.getByText('Folder 4')).toBeTruthy(); +}); + +it('shows less than 4 items if they do not fit', () => { + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + this: Element, + ) { + if (this instanceof HTMLUListElement) { + return 300; + } + + return 100; + }); + + render( + <Breadcrumbs maxItems={4}> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 5</Breadcrumbs.Item> + </Breadcrumbs>, + ); + + const {children} = screen.getByRole('list'); + expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy(); + expect(() => screen.getByText('Folder 1')).toThrow(); + expect(() => screen.getByText('Folder 2')).toThrow(); + expect(() => screen.getByText('Folder 3')).toThrow(); + expect(() => screen.getByText('Folder 4')).toThrow(); + expect(screen.getByText('Folder 5')).toBeTruthy(); +}); + +it('collapses root item if it does not fit', () => { + jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( + this: Element, + ) { + if (this instanceof HTMLUListElement) { + return 300; + } + + return 100; + }); + + render( + <Breadcrumbs showRoot> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 2</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 3</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 4</Breadcrumbs.Item> + <Breadcrumbs.Item>Folder 5</Breadcrumbs.Item> + </Breadcrumbs>, + ); + + const {children} = screen.getByRole('list'); + expect(() => screen.getByText('Folder 1')).toThrow(); + expect(within(children[0] as HTMLElement).getByRole('button')).toBeTruthy(); + expect(() => screen.getByText('Folder 2')).toThrow(); + expect(() => screen.getByText('Folder 3')).toThrow(); + expect(() => screen.getByText('Folder 4')).toThrow(); + expect(screen.getByText('Folder 5')).toBeTruthy(); +}); + +it('supports aria-label', function () { + render( + <Breadcrumbs aria-label="Test"> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const breadcrumbs = screen.getByRole('list'); + expect(breadcrumbs).toHaveAttribute('aria-label', 'Test'); +}); + +it('supports aria-labelledby', function () { + render( + <React.Fragment> + <span id="test">Test</span> + <Breadcrumbs aria-labelledby="test"> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + </Breadcrumbs> + </React.Fragment>, + ); + const breadcrumbs = screen.getByRole('list'); + expect(breadcrumbs).toHaveAttribute('aria-labelledby', 'test'); +}); + +it('supports aria-describedby', function () { + render( + <React.Fragment> + <span id="test">Test</span> + <Breadcrumbs aria-describedby="test"> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + </Breadcrumbs> + </React.Fragment>, + ); + const breadcrumbs = screen.getByRole('list'); + expect(breadcrumbs).toHaveAttribute('aria-describedby', 'test'); +}); + +it('supports custom props', function () { + render( + <Breadcrumbs data-testid="test"> + <Breadcrumbs.Item>Folder 1</Breadcrumbs.Item> + </Breadcrumbs>, + ); + const breadcrumbs = screen.getByRole('list'); + expect(breadcrumbs).toHaveAttribute('data-testid', 'test'); +}); + +it('should support links', async function () { + render( + <Breadcrumbs> + <Breadcrumbs.Item href="https://example.com">Example.com</Breadcrumbs.Item> + <Breadcrumbs.Item href="https://example.com/foo">Foo</Breadcrumbs.Item> + <Breadcrumbs.Item href="https://example.com/foo/bar">Bar</Breadcrumbs.Item> + <Breadcrumbs.Item href="https://example.com/foo/bar/baz">Baz</Breadcrumbs.Item> + <Breadcrumbs.Item href="https://example.com/foo/bar/baz/qux">Qux</Breadcrumbs.Item> + </Breadcrumbs>, + ); + + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(3); + expect(links[0]).toHaveAttribute('href', 'https://example.com/foo/bar'); + expect(links[1]).toHaveAttribute('href', 'https://example.com/foo/bar/baz'); + expect(links[2]).toHaveAttribute('href', 'https://example.com/foo/bar/baz/qux'); + + const menuButton = screen.getByRole('button'); + await userEvent.click(menuButton); + + const menu = screen.getByRole('menu'); + const items = within(menu).getAllByRole('menuitem'); + expect(items).toHaveLength(2); + expect(items[0].tagName).toBe('A'); + expect(items[0]).toHaveAttribute('href', 'https://example.com'); + expect(items[1].tagName).toBe('A'); + expect(items[1]).toHaveAttribute('href', 'https://example.com/foo'); +}); + +it('should support RouterProvider', async () => { + /* + declare module '@gravity-ui/uikit' { + interface RouterConfig { + routerOptions: { + foo: string; + }; + } + } + */ + const navigate = jest.fn(); + render( + <Breadcrumbs navigate={navigate}> + <Breadcrumbs.Item href="/" routerOptions={{foo: 'bar'} as any}> + Example.com + </Breadcrumbs.Item> + <Breadcrumbs.Item href="/foo" routerOptions={{foo: 'foo'} as any}> + Foo + </Breadcrumbs.Item> + <Breadcrumbs.Item href="/foo/bar" routerOptions={{foo: 'bar'} as any}> + Bar + </Breadcrumbs.Item> + <Breadcrumbs.Item href="/foo/bar/baz" routerOptions={{foo: 'bar'} as any}> + Baz + </Breadcrumbs.Item> + <Breadcrumbs.Item href="/foo/bar/baz/qux" routerOptions={{foo: 'bar'} as any}> + Qux + </Breadcrumbs.Item> + </Breadcrumbs>, + ); + + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/foo/bar'); + await userEvent.click(links[0]); + expect(navigate).toHaveBeenCalledWith('/foo/bar', {foo: 'bar'}); + navigate.mockReset(); + + const menuButton = screen.getByRole('button'); + await userEvent.click(menuButton); + + const menu = screen.getByRole('menu'); + const items = within(menu).getAllByRole('menuitem'); + expect(items[1]).toHaveAttribute('href', '/foo'); + await userEvent.click(items[1]); + expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'foo'}); +}); diff --git a/src/components/lab/Breadcrumbs/i18n/en.json b/src/components/lab/Breadcrumbs/i18n/en.json new file mode 100644 index 0000000000..34d12c4a1a --- /dev/null +++ b/src/components/lab/Breadcrumbs/i18n/en.json @@ -0,0 +1,4 @@ +{ + "breadcrumbs": "Breadcrumbs", + "label_more": "Show more" +} diff --git a/src/components/lab/Breadcrumbs/i18n/index.ts b/src/components/lab/Breadcrumbs/i18n/index.ts new file mode 100644 index 0000000000..c69f26ae7e --- /dev/null +++ b/src/components/lab/Breadcrumbs/i18n/index.ts @@ -0,0 +1,8 @@ +import {addComponentKeysets} from '../../../utils/addComponentKeysets'; + +import en from './en.json'; +import ru from './ru.json'; + +const COMPONENT = 'lab/Breadcrumbs'; + +export default addComponentKeysets({en, ru}, COMPONENT); diff --git a/src/components/lab/Breadcrumbs/i18n/ru.json b/src/components/lab/Breadcrumbs/i18n/ru.json new file mode 100644 index 0000000000..4f06a5a545 --- /dev/null +++ b/src/components/lab/Breadcrumbs/i18n/ru.json @@ -0,0 +1,4 @@ +{ + "breadcrumbs": "Навигация", + "label_more": "Показать больше" +} diff --git a/src/components/lab/Breadcrumbs/index.ts b/src/components/lab/Breadcrumbs/index.ts new file mode 100644 index 0000000000..ce977548b1 --- /dev/null +++ b/src/components/lab/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/components/lab/Breadcrumbs/utils.ts b/src/components/lab/Breadcrumbs/utils.ts new file mode 100644 index 0000000000..c1ee9352d2 --- /dev/null +++ b/src/components/lab/Breadcrumbs/utils.ts @@ -0,0 +1,24 @@ +import {block} from '../../utils/cn'; + +interface Modifiers { + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; +} +export function shouldClientNavigate(link: HTMLAnchorElement, modifiers: Modifiers) { + // Use getAttribute here instead of link.target. Firefox will default link.target to "_parent" when inside an iframe. + const target = link.getAttribute('target'); + return ( + link.href && + (!target || target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + !modifiers.metaKey && // open in new tab (mac) + !modifiers.ctrlKey && // open in new tab (windows) + !modifiers.altKey && // download + !modifiers.shiftKey + ); +} + +export const b = block('breadcrumbs2'); diff --git a/src/components/types.ts b/src/components/types.ts index 7fb4f4d49a..a4ce1ff1ca 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -63,3 +63,32 @@ export interface ControlGroupProps<ValueType extends string = string> { 'aria-label'?: string; 'aria-labelledby'?: string; } + +export type Key = string | number; + +export interface RouterConfig {} + +export type Href = RouterConfig extends {href: infer H} ? H : string; +export type RouterOptions = RouterConfig extends {routerOptions: infer O} ? O : never; + +export interface AriaLabelingProps { + /** + * Defines a string value that labels the current element. + */ + 'aria-label'?: string; + + /** + * Identifies the element (or elements) that labels the current element. + */ + 'aria-labelledby'?: string; + + /** + * Identifies the element (or elements) that describes the object. + */ + 'aria-describedby'?: string; + + /** + * Identifies the element (or elements) that provide a detailed, extended description for the object. + */ + 'aria-details'?: string; +} diff --git a/src/components/utils/filterDOMProps.ts b/src/components/utils/filterDOMProps.ts new file mode 100644 index 0000000000..3121065071 --- /dev/null +++ b/src/components/utils/filterDOMProps.ts @@ -0,0 +1,46 @@ +import type {AriaLabelingProps} from '../types'; + +const DOMPropNames = new Set(['id']); + +const labelablePropNames = new Set([ + 'aria-label', + 'aria-labelledby', + 'aria-describedby', + 'aria-details', +]); + +interface Options { + /** + * If labelling associated aria properties should be included in the filter. + */ + labelable?: boolean; + /** + * A Set of other property names that should be included in the filter. + */ + propNames?: Set<string>; +} + +const propRe = /^(data-.*)$/; + +export function filterDOMProps( + props: {id?: string} & AriaLabelingProps, + options: Options = {}, +): {id?: string} & AriaLabelingProps { + const {labelable, propNames} = options; + const filteredProps = {}; + + for (const prop in props) { + if ( + Object.prototype.hasOwnProperty.call(props, prop) && + (DOMPropNames.has(prop) || + (labelable && labelablePropNames.has(prop)) || + propNames?.has(prop) || + propRe.test(prop)) + ) { + // @ts-expect-error + filteredProps[prop] = props[prop]; + } + } + + return filteredProps; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a98690749..c19b579ae2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,6 +9,7 @@ export * from './useIntersection'; export * from './useListNavigation'; export * from './useOutsideClick'; export * from './usePortalContainer'; +export * from './useResizeObserver'; export * from './useSelect'; export * from './useTimeout'; export * from './useViewportSize'; diff --git a/src/hooks/useResizeObserver/README.md b/src/hooks/useResizeObserver/README.md new file mode 100644 index 0000000000..39ed376917 --- /dev/null +++ b/src/hooks/useResizeObserver/README.md @@ -0,0 +1,18 @@ +<!--GITHUB_BLOCK--> + +# useResizeObserver + +<!--/GITHUB_BLOCK--> + +```tsx +import {useResizeObserver} from '@gravity-ui/uikit'; +``` + +Custom hook that observes the change of the size of an element using the [ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). + +## Properties + +| Name | Description | Type | Default | +| :------ | :-------------------------------------------------------- | :---------------: | :-----: | +| ref | Ref-link to target of observation | `React.RefObject` | | +| handler | Callback when a size of the observation target is changed | `() => void` | | diff --git a/src/hooks/useResizeObserver/index.ts b/src/hooks/useResizeObserver/index.ts new file mode 100644 index 0000000000..6404a2459d --- /dev/null +++ b/src/hooks/useResizeObserver/index.ts @@ -0,0 +1 @@ +export {useResizeObserver} from './useResizeObserver'; diff --git a/src/hooks/useResizeObserver/useResizeObserver.ts b/src/hooks/useResizeObserver/useResizeObserver.ts new file mode 100644 index 0000000000..c95193d741 --- /dev/null +++ b/src/hooks/useResizeObserver/useResizeObserver.ts @@ -0,0 +1,34 @@ +import React from 'react'; + +interface UseResizeObserverProps<T> { + ref: React.RefObject<T | null | undefined> | undefined; + onResize: () => void; +} + +export function useResizeObserver<T extends Element>({ref, onResize}: UseResizeObserverProps<T>) { + React.useEffect(() => { + const element = ref?.current; + if (!element) { + return undefined; + } + + if (typeof window.ResizeObserver === 'undefined') { + window.addEventListener('resize', onResize, false); + return () => { + window.removeEventListener('resize', onResize, false); + }; + } + + const observer = new ResizeObserver((entries) => { + if (!entries.length) { + return; + } + onResize(); + }); + + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, [ref, onResize]); +} diff --git a/src/unstable.ts b/src/unstable.ts index 27133ee61c..1d5149a1de 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -24,3 +24,12 @@ export { TreeList as unstable_TreeList, type TreeListProps as unstable_TreeListProps, } from './components/TreeList'; + +export { + Breadcrumbs as unstable_Breadcrumbs, + BreadcrumbsItem as unstable_BreadcrumbsItem, +} from './components/lab/Breadcrumbs'; +export type { + BreadcrumbsProps as unstable_BreadcrumbsProps, + BreadcrumbsItemProps as unstable_BreadcrumbsItemProps, +} from './components/lab/Breadcrumbs';