From f7064c71bd0064462855f6ed9f94f9e7bc6adf6f Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Tue, 9 Apr 2024 18:57:16 +0200 Subject: [PATCH] feat(Breadcrumbs): new component --- .../lab/Breadcrumbs/BreadcrumbItem.tsx | 91 ++++ .../lab/Breadcrumbs/Breadcrumbs.scss | 105 ++++ .../lab/Breadcrumbs/Breadcrumbs.tsx | 272 +++++++++++ .../lab/Breadcrumbs/BreadcrumbsSeparator.tsx | 16 + src/components/lab/Breadcrumbs/README.md | 455 ++++++++++++++++++ .../__stories__/Breadcrumbs.stories.tsx | 134 ++++++ .../lab/Breadcrumbs/__stories__/Docs.mdx | 37 ++ .../__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 | 6 + 20 files changed, 1570 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..8df040d369 --- /dev/null +++ b/src/components/lab/Breadcrumbs/BreadcrumbItem.tsx @@ -0,0 +1,91 @@ +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 BreadcrumbItemProps extends BreadcrumbsItemProps { + onAction?: () => void; + title?: string; + isCurrent?: boolean; + isMenu?: boolean; + disabled?: boolean; + navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; +} +export function BreadcrumbItem(props: BreadcrumbItemProps) { + 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.isCurrent) { + 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.isCurrent; + let linkProps: React.AllHTMLAttributes = { + title, + onClick: handleAction, + 'aria-disabled': isDisabled ? true : undefined, + }; + if (Element === 'a') { + linkProps.href = props.href; + linkProps.target = props.target; + linkProps.rel = props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel; + } else { + linkProps.role = 'link'; + linkProps.tabIndex = isDisabled ? undefined : 0; + linkProps.onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleAction(event); + } + }; + } + + if (props.isCurrent) { + linkProps['aria-current'] = 'page'; + } + + if (props.isMenu) { + 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..3615735884 --- /dev/null +++ b/src/components/lab/Breadcrumbs/Breadcrumbs.scss @@ -0,0 +1,105 @@ +@use '../../variables'; +@use '../../../../styles/mixins'; + +$block: '.#{variables.$ns}breadcrumbs2'; + +#{$block} { + &__list { + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + + list-style-type: none; + margin: 0; + padding: 0; + } + + &__list-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; + padding: 0 var(--g-spacing-2); + 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); + outline-offset: -2px; + } + } + + &__divider { + display: flex; + align-items: center; + color: var(--g-color-text-secondary); + } + + &__more-button { + --g-button-border-radius: var(--g-focus-border-radius); + --g-button-focus-outline-offset: -2px; + } + + &__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..b89f53db6d --- /dev/null +++ b/src/components/lab/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,272 @@ +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; + target?: React.HTMLAttributeAnchorTarget; + rel?: string; + '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 navProps = { + ...filterDOMProps(props, {labelable: true}), + 'aria-label': props['aria-label'] || i18n('breadcrumbs'), + 'data-qa': props.qa, + }; + + const innerRef = React.useRef(null); + const containerRef = useForkRef(ref, innerRef); + + 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 listRef = React.useRef(null); + 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: innerRef, + 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 ( + + ); +}) as unknown as BreadcrumbsComponent; + +type BreadcrumbsComponent = React.FunctionComponent< + BreadcrumbsProps & {ref?: React.Ref} +> & { + Item: typeof Item; +}; + +Breadcrumbs.Item = Item; +Breadcrumbs.displayName = 'Breadcrumbs'; 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..5a524791ac --- /dev/null +++ b/src/components/lab/Breadcrumbs/README.md @@ -0,0 +1,455 @@ + + +# 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 ( +
    + {/*...*/} +
    + ); +} +``` + +## Properties + +| Name | Description | Type | Default | +| :--------------- | :-------------------------------------------------------------------- | :----------------------------------------- | :------ | +| children | Breadcrumb items. | `React.ReactElement` | | +| 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..1262e516a4 --- /dev/null +++ b/src/components/lab/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx @@ -0,0 +1,134 @@ +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 = { + title: 'Lab/Breadcrumbs', + component: Breadcrumbs, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + Region + Country + City + District + Street + + ), +}; + +export const Events: Story = { + render: function BreadcrumbsEvents(props) { + 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'}, + ]; + return ( +
    + + {items.map((i) => ( + {i.label} + ))} + +

    You clicked item ID: {currentId}

    +
    + ); + }, +}; + +export const Links: Story = { + render: (args) => ( + + + Home + + + Components + + + Breadcrumbs + + + ), +}; + +export const RootContext: Story = { + render: (args) => ( + + + Home + Trendy + March 2020 Assets + Winter + Holiday + + + ), +}; + +export const Separator: Story = { + render: (args) => { + const breadcrumbs = [ + uikit, + components, + Breadcrumbs, + ]; + return ( +
    + + {breadcrumbs} + + + {breadcrumbs} + + }> + {breadcrumbs} + +
    + ); + }, +}; + +export const WithIcons: Story = { + render: (args) => ( + + + + uikit + + + + + components + + + + + + + Breadcrumbs + + + + + ), +}; diff --git a/src/components/lab/Breadcrumbs/__stories__/Docs.mdx b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx new file mode 100644 index 0000000000..53951113e3 --- /dev/null +++ b/src/components/lab/Breadcrumbs/__stories__/Docs.mdx @@ -0,0 +1,37 @@ +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 = () => ; +export const BreadcrumbsEvents = () => ; +export const BreadcrumbsLinks = () => ; +export const BreadcrumbsRootContext = () => ; +export const BreadcrumbsSeparator = () => ; +export const BreadcrumbsWithIcons = () => ; + + + + + {Readme} + 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..3ed0880017 --- /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( + + Folder 1 + Folder 2 + Folder 3 + , + ); + 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 | undefined; + const Component = () => { + ref = React.useRef(); + return ( + + Folder 1 + + ); + }; + render(); + const breadcrumb = screen.getByLabelText('breadcrumbs-test'); + expect(breadcrumb).toBe(ref?.current); +}); + +it('shows four items with no menu', () => { + render( + + Folder 1 + Folder 2 + Folder 3 + Folder 4 + , + ); + 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( + + Folder 1 + Folder 2 + Folder 3 + Folder 4 + , + ); + 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( + + Folder 1 + Folder 2 + Folder 3 + Folder 4 + , + ); + 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( + + Folder 1 + Folder 2 + Folder 3 + Folder 4 + Folder 5 + , + ); + + 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( + + Folder 1 + Folder 2 + Folder 3 + Folder 4 + Folder 5 + , + ); + + 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( + + Folder 1 + , + ); + const breadcrumbs = screen.getByRole('navigation'); + expect(breadcrumbs).toHaveAttribute('aria-label', 'Test'); +}); + +it('supports aria-labelledby', function () { + render( + + Test + + Folder 1 + + , + ); + const breadcrumbs = screen.getByRole('navigation'); + expect(breadcrumbs).toHaveAttribute('aria-labelledby', 'test'); +}); + +it('supports aria-describedby', function () { + render( + + Test + + Folder 1 + + , + ); + const breadcrumbs = screen.getByRole('navigation'); + expect(breadcrumbs).toHaveAttribute('aria-describedby', 'test'); +}); + +it('supports custom props', function () { + render( + + Folder 1 + , + ); + const breadcrumbs = screen.getByRole('navigation'); + expect(breadcrumbs).toHaveAttribute('data-testid', 'test'); +}); + +it('should support links', async function () { + render( + + Example.com + Foo + Bar + Baz + Qux + , + ); + + 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( + + + Example.com + + + Foo + + + Bar + + + Baz + + + Qux + + , + ); + + 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 { '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; +} + +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 @@ + + +# useResizeObserver + + + +```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 { + ref: React.RefObject | undefined; + onResize: () => void; +} + +export function useResizeObserver({ref, onResize}: UseResizeObserverProps) { + 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..f2bd7a9a01 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -24,3 +24,9 @@ export { TreeList as unstable_TreeList, type TreeListProps as unstable_TreeListProps, } from './components/TreeList'; + +export {Breadcrumbs as unstable_Breadcrumbs} from './components/lab/Breadcrumbs'; +export type { + BreadcrumbsProps as unstable_BreadcrumbsProps, + BreadcrumbsItemProps as unstable_BreadcrumbsItemProps, +} from './components/lab/Breadcrumbs';