diff --git a/src/components/Breadcrumbs/BreadcrumbItem.tsx b/src/components/Breadcrumbs/BreadcrumbItem.tsx deleted file mode 100644 index 78b71522c..000000000 --- a/src/components/Breadcrumbs/BreadcrumbItem.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import type * as 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) { - 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; - 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'] = props['aria-current'] ?? 'page'; - } - - if (props.itemType === 'menu') { - linkProps = {}; - } - - return ( - - {props.children} - - ); -} - -BreadcrumbItem.displayName = 'Breadcrumbs.Item'; diff --git a/src/components/Breadcrumbs/Breadcrumbs.scss b/src/components/Breadcrumbs/Breadcrumbs.scss index 49530cbe4..5ee278ac0 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.scss +++ b/src/components/Breadcrumbs/Breadcrumbs.scss @@ -25,6 +25,8 @@ $block: '.#{variables.$ns}breadcrumbs'; &:last-child { font-weight: var(--g-text-accent-font-weight); overflow: hidden; + margin: -2px; + padding: 2px; #{$block}__link { @include mixins.overflow-ellipsis(); @@ -65,6 +67,7 @@ $block: '.#{variables.$ns}breadcrumbs'; &:focus-visible { outline: 2px solid var(--g-color-line-focus); + outline-offset: 0; } } @@ -77,34 +80,28 @@ $block: '.#{variables.$ns}breadcrumbs'; &__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 { + --g-list-item-view-spacer-size: 8px; + max-width: 320px; + padding: var(--g-spacing-1); + } - &__popup_staircase { - $menu: '.#{variables.$ns}menu'; - $staircaseLength: 10; - #{$menu} { - #{$menu}__list-item { - #{$menu}__item[class] { - padding-inline-start: 8px * $staircaseLength; - } - } + &-link { + text-decoration: none; + cursor: default; - @for $i from 0 through $staircaseLength { - #{$menu}__list-item:nth-child(#{$i}) { - #{$menu}__item[class] { - padding-inline-start: 8px * $i; - } - } + &:not([aria-disabled]) { + cursor: pointer; } } } + + &__item:first-child &__menu { + margin-inline-start: 0; + } } diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index b4858e00f..a380a57ad 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -3,39 +3,18 @@ import * as 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 type {AriaLabelingProps, DOMProps, Key, QAProps} from '../types'; import {filterDOMProps} from '../utils/filterDOMProps'; -import {BreadcrumbItem} from './BreadcrumbItem'; +import {BreadcrumbsDropdownMenu} from './BreadcrumbsDropdownMenu'; +import {BreadcrumbsItem} from './BreadcrumbsItem'; +import type {BreadcrumbsItemInnerProps} from './BreadcrumbsItem'; import {BreadcrumbsSeparator} from './BreadcrumbsSeparator'; -import i18n from './i18n'; -import {b, shouldClientNavigate} from './utils'; +import {b} 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; - 'aria-current'?: React.AriaAttributes['aria-current']; - routerOptions?: RouterOptions; - disabled?: boolean; -} - -function Item(_props: BreadcrumbsItemProps): React.ReactElement | null { - return null; -} - export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps { id?: string; showRoot?: boolean; @@ -43,8 +22,8 @@ export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps { maxItems?: number; popupStyle?: 'staircase'; popupPlacement?: PopupPlacement; - children: React.ReactElement | React.ReactElement[]; - navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void; + itemComponent?: React.ElementType; + children: React.ReactNode; disabled?: boolean; onAction?: (key: Key) => void; } @@ -155,7 +134,6 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }); - const {navigate} = props; let contents = items; if (items.length > visibleItemsCount) { contents = []; @@ -170,53 +148,32 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } 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}) => ( - - )} - /> - + + {hiddenItems.map((child, index) => { + const Component = props.itemComponent ?? BreadcrumbsItem; + const key = child.key ?? index; + const handleAction = () => { + if (typeof props.onAction === 'function') { + props.onAction(key); + } + }; + const innerProps: BreadcrumbsItemInnerProps = { + __index: index, + __disabled: props.disabled || child.props.disabled, + __onAction: handleAction, + }; + return ( + + {child.props.children} + + ); + })} + ); contents.push(menuItem); @@ -224,7 +181,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } const lastIndex = contents.length - 1; - const breadcrumbItems = contents.map((child, index) => { + const breadcrumbsItems = contents.map((child, index) => { const isCurrent = index === lastIndex; const key = child.key ?? index; const handleAction = () => { @@ -233,18 +190,26 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }; + const {'data-breadcrumbs-menu-item': isMenu, ...childProps} = child.props; + let item: React.ReactNode; + if (isMenu) { + item = child; + } else { + const Component = props.itemComponent ?? BreadcrumbsItem; + const innerProps: BreadcrumbsItemInnerProps = { + __current: isCurrent, + __disabled: props.disabled || childProps.disabled, + __onAction: handleAction, + }; + item = ( + + {childProps.children} + + ); + } return (
  • - - {child.props.children} - + {item} {isCurrent ? null : }
  • ); @@ -257,7 +222,7 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( className={b(null, props.className)} style={props.style} > - {breadcrumbItems} + {breadcrumbsItems} ); }) as unknown as BreadcrumbsComponent; @@ -265,10 +230,8 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( type BreadcrumbsComponent = React.FunctionComponent< BreadcrumbsProps & {ref?: React.Ref} > & { - Item: typeof Item; + Item: typeof BreadcrumbsItem; }; -Breadcrumbs.Item = Item; +Breadcrumbs.Item = BreadcrumbsItem; Breadcrumbs.displayName = 'Breadcrumbs'; - -export {Item as BreadcrumbsItem}; diff --git a/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx b/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx new file mode 100644 index 000000000..7dcccac0f --- /dev/null +++ b/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx @@ -0,0 +1,112 @@ +'use client'; + +import * as React from 'react'; + +import { + useClick, + useDismiss, + useFloatingRootContext, + useInteractions, + useListNavigation, + useRole, +} from '@floating-ui/react'; +import type {UseInteractionsReturn} from '@floating-ui/react'; + +import {Button} from '../Button'; +import {Popup} from '../Popup'; +import type {PopupPlacement} from '../Popup'; + +import i18n from './i18n'; +import {b} from './utils'; + +interface DropdownMenuProps { + children: React.ReactNode; + disabled?: boolean; + popupPlacement?: PopupPlacement; + popupStyle?: 'staircase'; +} + +interface MenuContext { + isMenu: boolean; + activeIndex: null | number; + getItemProps: UseInteractionsReturn['getItemProps']; + listItemsRef: {current: Array}; + popupStyle: undefined | 'staircase'; +} + +const menuContext = React.createContext({ + isMenu: false, + activeIndex: null as null | number, + getItemProps: (props = {}) => props, + listItemsRef: {current: []}, + popupStyle: undefined, +}); + +export function BreadcrumbsDropdownMenu({ + children, + disabled, + popupPlacement, + popupStyle, +}: DropdownMenuProps) { + const [reference, setReference] = React.useState(null); + const [floating, setFloating] = React.useState(null); + const [activeIndex, setActiveIndex] = React.useState(null); + const [open, setOpen] = React.useState(false); + + const context = useFloatingRootContext({ + open, + onOpenChange: setOpen, + elements: {reference, floating}, + }); + + const listItemsRef = React.useRef>([]); + + const listNavigation = useListNavigation(context, { + enabled: !disabled, + listRef: listItemsRef, + activeIndex, + onNavigate: setActiveIndex, + loop: true, + }); + + const dismiss = useDismiss(context, {enabled: !disabled}); + const click = useClick(context, {enabled: !disabled}); + const role = useRole(context, {role: 'menu'}); + + const interactions = [click, dismiss, listNavigation, role]; + const {getReferenceProps, getItemProps} = useInteractions(interactions); + + return ( +
    + + + + {children} + + +
    + ); +} + +export function useMenuContext() { + return React.useContext(menuContext); +} diff --git a/src/components/Breadcrumbs/BreadcrumbsItem.tsx b/src/components/Breadcrumbs/BreadcrumbsItem.tsx new file mode 100644 index 000000000..08e1adf53 --- /dev/null +++ b/src/components/Breadcrumbs/BreadcrumbsItem.tsx @@ -0,0 +1,155 @@ +'use client'; + +import * as React from 'react'; + +import {ListItemView} from '../lab/ListItemView/ListItemView'; +import {filterDOMProps} from '../utils/filterDOMProps'; + +import {useMenuContext} from './BreadcrumbsDropdownMenu'; +import {b} from './utils'; + +export interface BreadcrumbsItemInnerProps { + __disabled?: boolean; + __onAction?: () => void; + __current?: boolean; + __index?: number; +} + +export interface BreadcrumbsItemProps extends React.AnchorHTMLAttributes { + disabled?: boolean; + children?: React.ReactNode; +} + +function BreadcrumbsItem(props: BreadcrumbsItemProps, ref: React.ForwardedRef) { + const domProps = filterDOMProps(props, {labelable: true}); + + const { + href, + hrefLang, + target, + rel, + download, + ping, + referrerPolicy, + children, + __disabled: disabled, + __current: current, + __onAction: onAction, + __index: index, + ...restProps + } = props as BreadcrumbsItemProps & BreadcrumbsItemInnerProps; + + let title = props.title; + if (!title && typeof children === 'string') { + title = children; + } + + const handleAction = (event: React.MouseEvent) => { + if (disabled) { + event.preventDefault(); + return; + } + + if (typeof restProps.onClick === 'function') { + restProps.onClick(event); + } + + if (typeof onAction === 'function') { + onAction(); + } + }; + + const linkProps: React.AnchorHTMLAttributes = { + title, + onClick: handleAction, + 'aria-disabled': disabled ? true : undefined, + }; + + if (href) { + linkProps.href = href; + linkProps.hrefLang = hrefLang; + linkProps.target = target; + linkProps.rel = target === '_blank' && !rel ? 'noopener noreferrer' : rel; + linkProps.download = download; + linkProps.ping = ping; + linkProps.referrerPolicy = referrerPolicy; + linkProps.tabIndex = disabled ? -1 : undefined; + } else { + linkProps.role = 'link'; + linkProps.tabIndex = disabled ? undefined : 0; + linkProps.onKeyDown = (event) => { + if (disabled) { + event.preventDefault(); + return; + } + + if (typeof restProps.onKeyDown === 'function') { + restProps.onKeyDown(event); + } + + if (event.key === 'Enter') { + if (typeof onAction === 'function') { + onAction(); + } + } + }; + } + + if (current) { + linkProps['aria-current'] = props['aria-current'] ?? 'page'; + } + + const Element = href ? 'a' : 'span'; + + const {isMenu, getItemProps, listItemsRef, activeIndex, popupStyle} = useMenuContext(); + if (isMenu) { + const active = !disabled && activeIndex === index; + return ( + { + listItemsRef.current[index ?? 0] = node; + }} + nestedLevel={popupStyle === 'staircase' ? index : undefined} + tabIndex={active ? 0 : -1} + active={active} + size="m" + className={b('menu-link', props.className)} + component={Element} + disabled={disabled} + > + {children} + + ); + } + + return ( + + {children} + + ); +} + +BreadcrumbsItem.displayName = 'Breadcrumbs.Item'; + +const _BreadcrumbsItem = React.forwardRef(BreadcrumbsItem); +export {_BreadcrumbsItem as BreadcrumbsItem}; diff --git a/src/components/Breadcrumbs/README-ru.md b/src/components/Breadcrumbs/README-ru.md index b8fc37491..e4e0735e4 100644 --- a/src/components/Breadcrumbs/README-ru.md +++ b/src/components/Breadcrumbs/README-ru.md @@ -336,92 +336,80 @@ LANDING_BLOCK--> ### Интеграция с роутерами -Компонент `Breadcrumbs` принимает функцию навигации от роутера для программного управления навигацией на стороне клиента. -На примере ниже показана общая схема: + + +#### React Router ```jsx -function Header() { - const navigate = useNavigateFromYourRouter(); +import {useLinkClickHandler, useHref} from 'react-router'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; + +function RouterLink({to, ...rest}) { + const href = useHref(to); + const onClick = useLinkClickHandler(to); + return ; +} +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -#### React Router v5 +#### Next.js ```jsx -import {useHistory} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const history = useHistory(); +import Link from 'next/link'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; +function RouterLink({href, ...rest}) { return ( -
    - {/*...*/} -
    + + ; + ); } -``` - -#### React Router v6 - -```jsx -import {useNavigate} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const navigate = useNavigate(); +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -#### Next.js - -`App router` +#### Tanstack Router ```jsx -'use client'; - -import {useRouter} from 'next/navigation'; -import {Breadcrumbs} from '@gravity-ui/uikit'; +import {createLink} from '@tanstack/react-router'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; -function Header() { - const router = useRouter(); +const RouterLink = createLink(BreadcrumbsItem); +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -`Pages router` - -```jsx -import {useRouter} from 'next/router'; -import {Breadcrumbs} from '@gravity-ui/uikit'; + -function Header() { - const router = useRouter(); + - return ( -
    - {/*...*/} -
    - ); -} -``` + ### Области навигации diff --git a/src/components/Breadcrumbs/README.md b/src/components/Breadcrumbs/README.md index 93b4db32d..623787e94 100644 --- a/src/components/Breadcrumbs/README.md +++ b/src/components/Breadcrumbs/README.md @@ -336,92 +336,80 @@ LANDING_BLOCK--> ### Integration with routers -The `Breadcrumbs` component accepts navigate function received from your router for performing a client side navigation programmatically. -The following example shows the general pattern. + + +#### React Router ```jsx -function Header() { - const navigate = useNavigateFromYourRouter(); +import {useLinkClickHandler, useHref} from 'react-router'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; + +function RouterLink({to, ...rest}) { + const href = useHref(to); + const onClick = useLinkClickHandler(to); + return ; +} +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -#### React Router v5 +#### Next.js ```jsx -import {useHistory} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const history = useHistory(); +import Link from 'next/link'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; +function RouterLink({href, ...rest}) { return ( -
    - {/*...*/} -
    + + ; + ); } -``` - -#### React Router v6 - -```jsx -import {useNavigate} from 'react-router-dom'; -import {Breadcrumbs} from '@gravity-ui/uikit'; - -function Header() { - const navigate = useNavigate(); +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -#### Next.js - -`App router` +#### Tanstack Router ```jsx -'use client'; - -import {useRouter} from 'next/navigation'; -import {Breadcrumbs} from '@gravity-ui/uikit'; +import {createLink} from '@tanstack/react-router'; +import {Breadcrumbs, BreadcrumbsItem} from '@gravity-ui/uikit'; -function Header() { - const router = useRouter(); +const RouterLink = createLink(BreadcrumbsItem); +function Navigation() { return ( -
    - {/*...*/} -
    + + Home + Components + Breadcrumbs + ); } ``` -`Pages router` - -```jsx -import {useRouter} from 'next/router'; -import {Breadcrumbs} from '@gravity-ui/uikit'; + -function Header() { - const router = useRouter(); + - return ( -
    - {/*...*/} -
    - ); -} -``` + ### Landmarks diff --git a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-light-chromium-linux.png b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-light-chromium-linux.png index 58be657cf..25a93d011 100644 Binary files a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-light-chromium-linux.png and b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-light-chromium-linux.png differ diff --git a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-start-light-chromium-linux.png b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-start-light-chromium-linux.png index 71796efe3..e5d2cc2a3 100644 Binary files a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-start-light-chromium-linux.png and b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-bottom-start-light-chromium-linux.png differ diff --git a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-top-end-light-chromium-linux.png b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-top-end-light-chromium-linux.png index 3df78fa22..2736bff68 100644 Binary files a/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-top-end-light-chromium-linux.png and b/src/components/Breadcrumbs/__snapshots__/Breadcrumbs.visual.test.tsx-snapshots/Breadcrumbs-smoke-with-text-items-popupPlacement-top-end-light-chromium-linux.png differ diff --git a/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx index b9d99dd73..37db28f3f 100644 --- a/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx +++ b/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx @@ -7,6 +7,8 @@ import {Text} from '../../Text'; import {Box, Flex} from '../../layout'; import type {Key} from '../../types'; import {Breadcrumbs} from '../Breadcrumbs'; +import {BreadcrumbsItem} from '../BreadcrumbsItem'; +import type {BreadcrumbsItemProps} from '../BreadcrumbsItem'; const meta: Meta = { title: 'Components/Navigation/Breadcrumbs', @@ -152,3 +154,29 @@ export const DisabledItems = { ), } satisfies Story; + +export const ClientNavigation = { + render: (args) => { + return ( + + Home + Components + Breadcrumbs + + ); + }, +} satisfies Story; + +function RouterLink({to, ...rest}: {to: string} & Omit) { + const href = to; + return ( + { + e.preventDefault(); + alert(`navigate to ${to}`); + }} + /> + ); +} diff --git a/src/components/Breadcrumbs/__stories__/Docs.mdx b/src/components/Breadcrumbs/__stories__/Docs.mdx index ea094567f..55add523f 100644 --- a/src/components/Breadcrumbs/__stories__/Docs.mdx +++ b/src/components/Breadcrumbs/__stories__/Docs.mdx @@ -16,6 +16,7 @@ export const BreadcrumbsRootContext = () => ; export const BreadcrumbsWithIcons = () => ; export const BreadcrumbsLandmarks = () => ; +export const BreadcrumbsClientNavigation = () => ; export const BreadcrumbsDisabledItems = () => ( ); @@ -36,6 +37,7 @@ export const BreadcrumbsDisabledItems = () => ( BreadcrumbsWithIcons, BreadcrumbsLandmarks, BreadcrumbsDisabledItems, + BreadcrumbsClientNavigation, }, }} > diff --git a/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index f2da371ac..fb0d3a37f 100644 --- a/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -4,6 +4,8 @@ import {userEvent} from '@testing-library/user-event'; import {render, screen, within} from '../../../../test-utils/utils'; import {Breadcrumbs} from '../Breadcrumbs'; +import {BreadcrumbsItem} from '../BreadcrumbsItem'; +import type {BreadcrumbsItemProps} from '../BreadcrumbsItem'; beforeEach(() => { jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( @@ -237,34 +239,37 @@ it('should support links', async function () { expect(items[1]).toHaveAttribute('href', 'https://example.com/foo'); }); -it('should support RouterProvider', async () => { - /* - declare module '@gravity-ui/uikit' { - interface RouterConfig { - routerOptions: { - foo: string; - }; - } - } - */ +it('should support custom item component', async () => { const navigate = jest.fn(); + function RouterLink({ + href, + routerOptions, + ...rest + }: { + href: string; + routerOptions: {foo: string}; + } & Omit) { + return ( + navigate(href, routerOptions)} /> + ); + } render( - - + + Example.com - - + + Foo - - + + Bar - - + + Baz - - + + Qux - + , ); @@ -274,6 +279,9 @@ it('should support RouterProvider', async () => { expect(navigate).toHaveBeenCalledWith('/foo/bar', {foo: 'bar'}); navigate.mockReset(); + expect(links[2]).toHaveAttribute('aria-disabled', 'true'); + expect(links[2]).toHaveAttribute('aria-current', 'page'); + const menuButton = screen.getByRole('button'); await userEvent.click(menuButton); @@ -282,4 +290,6 @@ it('should support RouterProvider', async () => { expect(items[1]).toHaveAttribute('href', '/foo'); await userEvent.click(items[1]); expect(navigate).toHaveBeenCalledWith('/foo', {foo: 'foo'}); + + expect(items[0]).toHaveAttribute('aria-disabled', 'true'); }); diff --git a/src/components/Breadcrumbs/__tests__/Breadcrumbs.visual.test.tsx b/src/components/Breadcrumbs/__tests__/Breadcrumbs.visual.test.tsx index ce84f9a80..855b19b3e 100644 --- a/src/components/Breadcrumbs/__tests__/Breadcrumbs.visual.test.tsx +++ b/src/components/Breadcrumbs/__tests__/Breadcrumbs.visual.test.tsx @@ -32,7 +32,7 @@ test.describe('Breadcrumbs', {tag: '@Breadcrumbs'}, () => { await root.locator('button').click(); - await expect(page.locator(`ul[role="menu"]`)).toBeVisible(); + await expect(page.locator(`div[role="menu"]`)).toBeVisible(); await expectScreenshot({ themes: ['light'], diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts index ce977548b..22e97c82c 100644 --- a/src/components/Breadcrumbs/index.ts +++ b/src/components/Breadcrumbs/index.ts @@ -1 +1,5 @@ -export * from './Breadcrumbs'; +export {Breadcrumbs} from './Breadcrumbs'; +export type {BreadcrumbsProps} from './Breadcrumbs'; + +export {BreadcrumbsItem} from './BreadcrumbsItem'; +export type {BreadcrumbsItemProps} from './BreadcrumbsItem'; diff --git a/src/components/Breadcrumbs/utils.ts b/src/components/Breadcrumbs/utils.ts index baa7b708f..4e340bced 100644 --- a/src/components/Breadcrumbs/utils.ts +++ b/src/components/Breadcrumbs/utils.ts @@ -1,24 +1,3 @@ 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('breadcrumbs'); diff --git a/src/components/lab/ListItemView/ListItemView.scss b/src/components/lab/ListItemView/ListItemView.scss new file mode 100644 index 000000000..4c096b30d --- /dev/null +++ b/src/components/lab/ListItemView/ListItemView.scss @@ -0,0 +1,277 @@ +@use '../../variables'; +@use '../../../../styles/mixins'; + +$block: '.#{variables.$ns}lab-list-item-view'; + +/* ListItemView CSS API + --g-list-item-view-min-height + --g-list-item-view-border-radius + --g-list-item-view-padding-inline + --g-list-item-view-padding-block + --g-list-item-view-line-height + --g-list-item-view-controls-gap + --g-list-item-view-controls-size + --g-list-item-view-controls-border-radius + --g-list-item-view-controls-icon-size + --g-list-item-view-spacer-size + --g-list-item-view-background-color + --g-list-item-view-background-color-hover + --g-list-item-view-text-color + --g-list-item-view-description-color +*/ +#{$block} { + /* Sizes */ + --_--min-height: var(--g-list-item-view-min-height, #{variables.$m-height}); + --_--border-radius: var(--g-list-item-view-border-radius, var(--g-border-radius-m)); + --_--padding-inline: var(--g-list-item-view-padding-inline, var(--g-spacing-2)); + --_--padding-block: var(--g-list-item-view-padding-block, var(--g-spacing-1)); + --_--line-height: var(--g-list-item-view-line-height, 18px); + + --_--controls-gap: var(--g-list-item-view-controls-gap, var(--g-spacing-1)); + --_--controls-size: var(--g-list-item-view-controls-size, #{variables.$s-height}); + --_--controls-border-radius: var( + --g-list-item-view-controls-border-radius, + var(--g-border-radius-s) + ); + --_--controls-icon-size: var(--g-list-item-view-controls-icon-size, 16px); + + --_--spacer-size: var(--g-list-item-view-spacer-size, var(--_--controls-size)); + + /* Colors */ + --_--background-color: var(--g-list-item-view-background-color, var(--g-color-base-fill)); + --_--background-color-hover: var( + --g-list-item-view-background-color-hover, + var(--g-color-base-simple-hover) + ); + --_--background-color-disabled: var( + --g-list-item-view-background-color, + var(--g-color-base-fill) + ); + --_--background-color-active: var( + --g-list-item-view-background-color, + var(--g-color-base-generic-medium) + ); + --_--background-color-selected: var( + --g-list-item-view-background-color, + var(--g-color-base-selection) + ); + --_--background-color-selected-hover: var( + --g-list-item-view-background-color-hover, + var(--g-color-base-selection-hover) + ); + --_--text-color: var(--g-list-item-view-text-color, var(--g-color-text-primary)); + --_--text-color-disabled: var(--g-list-item-view-text-color, var(--g-color-text-hint)); + --_--description-color: var( + --g-list-item-view-description-color, + var(--g-color-text-secondary) + ); + --_--nested-level: 0; +} + +#{$block} { + display: grid; + box-sizing: border-box; + grid-template: + 'drag-handle spacer collapsed-toggle checked start-content content end-content' 1fr + 'drag-handle spacer collapsed-toggle checked start-content description end-content' auto + / auto auto auto auto auto 1fr auto; + align-items: center; + min-height: calc(var(--_--min-height) + var(--_--description-min-height, 0px)); + border-radius: var(--_--border-radius); + background: var(--_--background-color); + color: var(--_--text-color); + + padding-inline: var(--_--padding-inline); + padding-block: var(--_--padding-block); + outline: none; + + @include mixins.text-body-1; + + &_has-description { + --_--description-min-height: 16px; + } + + &__slot { + &:not(&_name_spacer) + &:not(&_name_description, &_name_spacer), + &:not(&_name_spacer) + & + &_name_description { + margin-inline-start: var(--_--controls-gap); + } + + display: flex; + justify-content: center; + align-items: center; + gap: var(--_--controls-gap); + margin-block: calc(-1 * var(--_--padding-block)); + + &_name { + &_drag-handle { + grid-area: drag-handle; + --g-button-height: var(--_--controls-size); + --g-button-border-radius: var(--_--controls-border-radius); + --g-button-background-color-hover: transparent; + } + + &_spacer { + grid-area: spacer; + width: calc(var(--_--spacer-size) * var(--_--nested-level)); + height: var(--_--controls-size); + } + + &_collapsed-toggle { + grid-area: collapsed-toggle; + --g-button-height: var(--_--controls-size); + --g-button-border-radius: var(--_--controls-border-radius); + --g-button-background-color-hover: transparent; + } + + &_checked { + grid-area: checked; + } + + &_start-content { + grid-area: start-content; + height: 100%; + } + + &_content { + grid-area: content; + justify-content: flex-start; + overflow: hidden; + min-width: 3ch; + line-height: var(--_--line-height); + margin-block: 0; + } + + &_description { + grid-area: description; + justify-content: flex-start; + overflow: hidden; + min-width: 3ch; + color: var(--_--description-color); + line-height: var(--_--line-height); + margin-block: 0; + } + + &_end-content { + grid-area: end-content; + height: 100%; + } + + &_container { + grid-row: 1 / -1; + grid-column: 1 / -1; + } + } + } + + &_size { + &_s { + --_--min-height: #{variables.$s-height}; + --_--border-radius: var(--g-border-radius-s); + --_--padding-inline: var(--g-spacing-2); + --_--padding-block: var(--g-spacing-half); + + --_--controls-gap: var(--g-spacing-2); + --_--controls-size: #{variables.$xs-height}; + --_--controls-border-radius: var(--g-border-radius-xs); + --_--controls-icon-size: 12px; + } + + &_m { + --_--min-height: #{variables.$m-height}; + --_--border-radius: var(--g-border-radius-m); + --_--padding-inline: var(--g-spacing-2); + --_--padding-block: var(--g-spacing-1); + + --_--controls-gap: var(--g-spacing-2); + --_--controls-size: #{variables.$s-height}; + --_--controls-border-radius: var(--g-border-radius-s); + --_--controls-icon-size: 16px; + } + + &_l { + --_--min-height: #{variables.$l-height}; + --_--border-radius: var(--g-border-radius-l); + --_--padding-inline: var(--g-spacing-2); + --_--padding-block: var(--g-spacing-2); + --_--controls-gap: var(--g-spacing-2); + --_--controls-size: #{variables.$m-height}; + --_--controls-border-radius: var(--g-border-radius-m); + --_--controls-icon-size: 16px; + } + + &_xl { + --_--min-height: #{variables.$xl-height}; + --_--border-radius: var(--g-border-radius-xl); + --_--padding-inline: var(--g-spacing-2); + --_--padding-block: var(--g-spacing-3); + + --_--controls-gap: var(--g-spacing-2); + --_--controls-size: #{variables.$l-height}; + --_--controls-border-radius: var(--g-border-radius-l); + --_--controls-icon-size: 16px; + + @include mixins.text-body-2; + } + } + + &_is-container { + --_--padding-inline: 0; + --_--padding-block: 0; + } + + &:focus { + --_--background-color: var(--_--background-color-hover); + } + + &:hover { + --_--background-color: var(--_--background-color-hover); + } + + &_active { + --_--background-color: var(--_--background-color-hover); + --_--background-color-hover: var(--_--background-color-active); + } + + &_selected { + --_--background-color: var(--_--background-color-selected); + --_--background-color-hover: var(--_--background-color-selected-hover); + } + + &_disabled { + &, + &:hover, + &:focus { + --_--background-color: var(--_--background-color-disabled); + --_--text-color: var(--_--text-color-disabled); + --_--description-color: var(--_--text-color-disabled); + outline: none; + } + } + + &__arrow { + &_direction { + &_bottom { + transform: rotate(0); + } + &_top { + transform: rotate(-180deg); + } + } + } + + &__checked { + display: flex; + justify-content: center; + align-items: center; + + width: var(--_--controls-size); + height: var(--_--controls-size); + color: var(--g-color-base-brand); + } + + &__icon { + width: var(--_--controls-icon-size); + height: var(--_--controls-icon-size); + } +} diff --git a/src/components/lab/ListItemView/ListItemView.tsx b/src/components/lab/ListItemView/ListItemView.tsx new file mode 100644 index 000000000..4ebd1484e --- /dev/null +++ b/src/components/lab/ListItemView/ListItemView.tsx @@ -0,0 +1,199 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ + +import * as React from 'react'; + +import {Check} from '@gravity-ui/icons'; +import {focusable} from 'tabbable'; + +import {useForkRef} from '../../../hooks'; +import {ArrowToggle} from '../../ArrowToggle'; +import {Button} from '../../Button'; +import {Icon} from '../../Icon'; +import type {DOMProps} from '../../types'; +import {block} from '../../utils/cn'; +import {filterDOMProps} from '../../utils/filterDOMProps'; + +import './ListItemView.scss'; + +const b = block('lab-list-item-view'); + +export interface ListItemViewProps extends DOMProps { + id?: string; + children: React.ReactNode; + size?: 's' | 'm' | 'l' | 'xl'; + selected?: boolean; + active?: boolean; + onClick?: (e: React.MouseEvent) => void; + disabled?: boolean; + selectionStyle?: 'check' | 'highlight' | 'none'; + collapsible?: boolean; + collapsed?: boolean; + onCollapseChange?: (collapsed: boolean) => void; + draggable?: boolean; + nestedLevel?: number; + startContent?: React.ReactNode; + description?: React.ReactNode; + endContent?: React.ReactNode; + isContainer?: boolean; + component?: T; +} + +export const ListItemView = React.forwardRef(ListItemViewComponent) as < + T extends React.ElementType = 'div', +>( + props: ListItemViewProps & Omit, keyof ListItemViewProps>, +) => React.ReactElement; + +export function ListItemViewComponent( + props: ListItemViewProps & Omit, keyof ListItemViewProps>, + ref: React.ForwardedRef, +) { + const { + size, + active, + selected, + disabled, + onClick, + selectionStyle, + className, + style, + collapsed, + onCollapseChange, + children, + isContainer = false, + component: Component = 'div', + collapsible: _collapsible, + description, + draggable: _draggable, + startContent: _startContent, + endContent: _endContent, + nestedLevel: _nestedLevel, + ...restProps + } = props; + const containerRef = React.useRef(null); + const componentRef = useForkRef(containerRef, ref); + return ( + { + if (disabled) { + e.preventDefault(); + return; + } + const target = e.target; + if ( + target instanceof Element && + containerRef.current && + focusable(containerRef.current).some((el) => el.contains(target)) + ) { + return; + } + + if (typeof onClick === 'function') { + onClick(e); + } else if (typeof onCollapseChange === 'function') { + onCollapseChange(!collapsed); + } + }} + > + {isContainer ? ( + {children} + ) : ( + {children} + )} + + ); +} + +function ListItemViewContent({ + selected, + disabled, + selectionStyle, + draggable, + nestedLevel, + collapsible, + collapsed, + onCollapseChange, + startContent, + children, + description, + endContent, +}: ListItemViewProps) { + return ( + + {draggable ? : null} + {nestedLevel ? : null} + {collapsible ? ( + + + + ) : null} + {selectionStyle === 'check' && ( + +
    + {selected ? : null} +
    +
    + )} + {startContent ? {startContent} : null} + {children} + {description ? {description} : null} + {endContent ? {endContent} : null} +
    + ); +} + +function Slot({ + name, + children, + className, + style, +}: { + name: + | 'drag-handle' + | 'spacer' + | 'collapsed-toggle' + | 'checked' + | 'start-content' + | 'content' + | 'description' + | 'end-content' + | 'container'; + children?: React.ReactNode; +} & DOMProps) { + return ( +
    + {children} +
    + ); +} diff --git a/src/components/types.ts b/src/components/types.ts index f592fbedd..2c30e002a 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -1,7 +1,7 @@ import type * as React from 'react'; export interface DOMProps { - style?: React.CSSProperties; + style?: CSSProperties; className?: string; } @@ -64,11 +64,6 @@ export interface ControlGroupProps extends Ar 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. @@ -97,3 +92,7 @@ export interface FocusEventHandlers { /** Handler that is called when the element loses focus. */ onBlur?: React.FocusEventHandler; } + +export interface CSSProperties extends React.CSSProperties { + [key: `--${string}`]: string | number; +}