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..83cd0dd18 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -3,48 +3,42 @@ 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 {BreadcrumbsItemView} from './BreadcrumbsItemView'; 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; -} +export type BreadcrumbsItemProps = + React.ComponentPropsWithoutRef & { + component?: T; + disabled?: boolean; + }; -function Item(_props: BreadcrumbsItemProps): React.ReactElement | null { +function Item( + _props: BreadcrumbsItemProps, +): React.ReactElement | null { return null; } -export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps { +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; + children: + | React.ReactElement> + | React.ReactElement>[]; disabled?: boolean; onAction?: (key: Key) => void; } @@ -155,7 +149,6 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }); - const {navigate} = props; let contents = items; if (items.length > visibleItemsCount) { contents = []; @@ -170,53 +163,30 @@ 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) => { + + {hiddenItems.map((child, index) => { + const Component = child.props.component ?? BreadcrumbsItemView; + return ( + { 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); - } + props.onAction(child.key ?? index); } - }, - }; - })} - popupProps={{ - className: b('popup', { - staircase: props.popupStyle === 'staircase', - }), - placement: props.popupPlacement, - }} - renderSwitcher={({onClick}) => ( - - )} - /> - + {child.props.children} + + ); + })} + ); contents.push(menuItem); @@ -233,18 +203,18 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs( } }; + const {component: Component = BreadcrumbsItemView, ...childProps} = child.props; return (
  • - - {child.props.children} - + {childProps.children} + {isCurrent ? null : }
  • ); diff --git a/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx b/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx new file mode 100644 index 000000000..511669800 --- /dev/null +++ b/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx @@ -0,0 +1,106 @@ +'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 i18n from './i18n'; +import {b} from './utils'; + +interface DropdownMenuProps { + children: React.ReactNode; + disabled?: boolean; + popupStyle?: 'staircase'; + component?: typeof BreadcrumbsDropdownMenu; +} + +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, popupStyle}: DropdownMenuProps) { + const [reference, setReference] = React.useState(null); + const [floating, setFloating] = React.useState(null); + const [activeIndex, setActiveIndex] = React.useState(0); + 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/BreadcrumbsItemView.tsx b/src/components/Breadcrumbs/BreadcrumbsItemView.tsx new file mode 100644 index 000000000..b3893022c --- /dev/null +++ b/src/components/Breadcrumbs/BreadcrumbsItemView.tsx @@ -0,0 +1,153 @@ +'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 BreadcrumbsItemViewProps extends React.AnchorHTMLAttributes { + children: React.ReactNode; + disabled?: boolean; + onAction?: () => void; + current?: boolean; + index?: number; +} + +function BreadcrumbsItem( + props: BreadcrumbsItemViewProps, + ref: React.ForwardedRef, +) { + const domProps = filterDOMProps(props, {labelable: true}); + + const { + disabled, + current, + href, + hrefLang, + target, + rel, + download, + ping, + referrerPolicy, + children, + onAction, + index, + ...restProps + } = props; + 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 as any); + } + + if (typeof onAction === 'function') { + onAction(); + } + }; + + const isDisabled = props.disabled; + const linkProps: React.AnchorHTMLAttributes = { + title, + onClick: handleAction, + 'aria-disabled': isDisabled ? 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 = isDisabled ? -1 : undefined; + } else { + linkProps.role = 'link'; + linkProps.tabIndex = isDisabled ? undefined : 0; + linkProps.onKeyDown = (event) => { + if (disabled) { + event.preventDefault(); + return; + } + + if (typeof restProps.onKeyDown === 'function') { + restProps.onKeyDown(event as any); + } + + 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 = !isDisabled && 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={isDisabled} + > + {children} + + ); + } + + return ( + + {children} + + ); +} + +BreadcrumbsItem.displayName = 'Breadcrumbs.Item'; + +export const BreadcrumbsItemView = React.forwardRef(BreadcrumbsItem); diff --git a/src/components/Breadcrumbs/README-ru.md b/src/components/Breadcrumbs/README-ru.md index b8fc37491..169cecb2b 100644 --- a/src/components/Breadcrumbs/README-ru.md +++ b/src/components/Breadcrumbs/README-ru.md @@ -336,92 +336,98 @@ LANDING_BLOCK--> ### Интеграция с роутерами -Компонент `Breadcrumbs` принимает функцию навигации от роутера для программного управления навигацией на стороне клиента. -На примере ниже показана общая схема: + + +#### React Router ```jsx -function Header() { - const navigate = useNavigateFromYourRouter(); +import {useLinkClickHandler, useHref} from 'react-router'; +import {Breadcrumbs, BreadcrumbsItemView} 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, BreadcrumbsItemView} 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, BreadcrumbsItemView} from '@gravity-ui/uikit'; -function Header() { - const router = useRouter(); +const RouterLink = createLink(BreadcrumbsItemView); +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..422de8b47 100644 --- a/src/components/Breadcrumbs/README.md +++ b/src/components/Breadcrumbs/README.md @@ -336,92 +336,98 @@ 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, BreadcrumbsItemView} 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, BreadcrumbsItemView} 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, BreadcrumbsItemView} from '@gravity-ui/uikit'; -function Header() { - const router = useRouter(); +const RouterLink = createLink(BreadcrumbsItemView); +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/__stories__/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx index b9d99dd73..a5f155480 100644 --- a/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx +++ b/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx @@ -7,6 +7,7 @@ import {Text} from '../../Text'; import {Box, Flex} from '../../layout'; import type {Key} from '../../types'; import {Breadcrumbs} from '../Breadcrumbs'; +import {BreadcrumbsItemView} from '../BreadcrumbsItemView'; const meta: Meta = { title: 'Components/Navigation/Breadcrumbs', @@ -152,3 +153,35 @@ export const DisabledItems = { ), } satisfies Story; + +export const ClientNavigation = { + render: (args) => { + return ( + + + Home + + + Components + + + Breadcrumbs + + + ); + }, +} satisfies Story; + +function RouterLink({to, ...rest}: {to: string; children: React.ReactNode}) { + 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..3703e5ba4 100644 --- a/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/src/components/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -4,6 +4,7 @@ import {userEvent} from '@testing-library/user-event'; import {render, screen, within} from '../../../../test-utils/utils'; import {Breadcrumbs} from '../Breadcrumbs'; +import {BreadcrumbsItemView} from '../BreadcrumbsItemView'; beforeEach(() => { jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function ( @@ -237,32 +238,48 @@ 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}; + children: React.ReactNode; + }) { + return ( + navigate(href, routerOptions)} + /> + ); + } render( - - + + Example.com - + Foo - + Bar - + Baz - + Qux , diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts index ce977548b..b51c2400b 100644 --- a/src/components/Breadcrumbs/index.ts +++ b/src/components/Breadcrumbs/index.ts @@ -1 +1,5 @@ -export * from './Breadcrumbs'; +export {Breadcrumbs, BreadcrumbsItem} from './Breadcrumbs'; +export type {BreadcrumbsProps} from './Breadcrumbs'; + +export {BreadcrumbsItemView} from './BreadcrumbsItemView'; +export type {BreadcrumbsItemViewProps} from './BreadcrumbsItemView'; 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..35f4ee311 --- /dev/null +++ b/src/components/lab/ListItemView/ListItemView.scss @@ -0,0 +1,285 @@ +@use '../../variables'; +@use '../../../../styles/mixins'; + +$block: '.#{variables.$ns}lab-list-item-view'; + +/* ListItemView CSS API */ +#{$block} { + /* Sizes */ + --_--g-list-item-view-height: var(--g-list-item-view-height, #{variables.$m-height}); + --_--g-list-item-view-border-radius: var( + --g-list-item-view-border-radius, + var(--g-border-radius-m) + ); + --_--g-list-item-view-padding-inline: var( + --g-list-item-view-padding-inline, + var(--g-spacing-2) + ); + --_--g-list-item-view-padding-block: var(--g-list-item-view-padding-block, var(--g-spacing-1)); + --_--g-list-item-view-line-height: var(--g-list-item-view-line-height, 18px); + + --_--g-list-item-view-controls-gap: var(--g-list-item-view-controls-gap, var(--g-spacing-1)); + --_--g-list-item-view-controls-size: var( + --g-list-item-view-controls-size, + #{variables.$s-height} + ); + --_--g-list-item-view-controls-border-radius: var( + --g-list-item-view-controls-border-radius, + var(--g-border-radius-s) + ); + --_--g-list-item-view-controls-icon-size: var(--g-list-item-view-controls-icon-size, 16px); + --_--g-list-item-view-spacer-size: var( + --g-list-item-view-spacer-size, + var(--_--g-list-item-view-controls-size) + ); + + /* Colors */ + --_--g-list-item-view-background: var(--g-list-item-view-background, var(--g-color-base-fill)); + --_--g-list-item-view-background-hover: var( + --g-list-item-view-background-hover, + var(--g-color-base-simple-hover) + ); + --_--g-list-item-view-background-disabled: var( + --g-list-item-view-background-disabled, + var(--g-color-base-fill) + ); + --_--g-list-item-view-background-selected: var( + --g-list-item-view-background-selected, + var(--g-color-base-selection) + ); + --_--g-list-item-view-background-selected-hover: var( + --g-list-item-view-background-selected-hover, + var(--g-color-base-selection-hover) + ); + --_--g-list-item-view-focus-outline-width: var(--g-list-item-view-focus-outline-width, 2px); + --_--g-list-item-view-focus-outline-style: var(--g-list-item-view-focus-outline-style, solid); + --_--g-list-item-view-focus-outline-color: var( + --g-list-item-view-focus-outline-color, + var(--g-color-line-focus) + ); + --_--g-list-item-view-focus-outline-offset: var(--g-list-item-view-focus-outline-offset, 0); + --_--g-list-item-view-color: var(--g-list-item-view-color, var(--g-color-text-primary)); + --_--g-list-item-view-color-description: var(--g-color-text-secondary); + --_--g-list-item-view-color-disabled: var( + --g-list-item-view-color-disabled, + var(--g-color-text-hint) + ); + --_--g-list-item-view-nested-level: var(--g-list-item-view-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(--_--g-list-item-view-height) + var(--_--g-list-item-view-description-height, 0px) + ); + border-radius: var(--_--g-list-item-view-border-radius); + background: var(--_--g-list-item-view-background); + color: var(--_--g-list-item-view-color); + + padding-inline: var(--_--g-list-item-view-padding-inline); + padding-block: var(--_--g-list-item-view-padding-block); + outline: none; + + @include mixins.text-body-1; + + &:focus-visible { + outline: var(--_--g-list-item-view-focus-outline-width) + var(--_--g-list-item-view-focus-outline-style) + var(--_--g-list-item-view-focus-outline-color); + outline-offset: var(--_--g-list-item-view-focus-outline-offset); + --_--g-list-item-view-background: var(--_--g-list-item-view-background-hover); + } + + &_has-description { + --_--g-list-item-view-description-height: 16px; + } + + &__slot { + &:not(&_name_spacer) + &:not(&_name_description, &_name_spacer), + &:not(&_name_spacer) + & + &_name_description { + margin-inline-start: var(--_--g-list-item-view-controls-gap); + } + + display: flex; + justify-content: center; + align-items: center; + gap: var(--_--g-list-item-view-controls-gap); + margin-block: calc(-1 * var(--_--g-list-item-view-padding-block)); + + &_name { + &_drag-handle { + grid-area: drag-handle; + --g-button-height: var(--_--g-list-item-view-controls-size); + --g-button-border-radius: var(--_--g-list-item-view-controls-border-radius); + --g-button-background-color-hover: transparent; + } + + &_spacer { + grid-area: spacer; + width: calc( + var(--_--g-list-item-view-spacer-size) * var(--_--g-list-item-view-nested-level) + ); + height: var(--_--g-list-item-view-controls-size); + } + + &_collapsed-toggle { + grid-area: collapsed-toggle; + --g-button-height: var(--_--g-list-item-view-controls-size); + --g-button-border-radius: var(--_--g-list-item-view-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(--_--g-list-item-view-line-height); + margin-block: 0; + } + + &_description { + grid-area: description; + justify-content: flex-start; + overflow: hidden; + min-width: 3ch; + color: var(--_--g-list-item-view-color-description); + line-height: var(--_--g-list-item-view-line-height); + margin-block: 0; + } + + &_end-content { + grid-area: end-content; + height: 100%; + } + + &_container { + grid-row: 1 / -1; + grid-column: 1 / -1; + } + } + } + + &_size { + &_s { + --_--g-list-item-view-height: #{variables.$s-height}; + --_--g-list-item-view-border-radius: var(--g-border-radius-s); + --_--g-list-item-view-padding-inline: var(--g-spacing-2); + --_--g-list-item-view-padding-block: var(--g-spacing-half); + + --_--g-list-item-view-controls-gap: var(--g-spacing-2); + --_--g-list-item-view-controls-size: #{variables.$xs-height}; + --_--g-list-item-view-controls-border-radius: var(--g-border-radius-xs); + --_--g-list-item-view-controls-icon-size: 12px; + } + + &_m { + --_--g-list-item-view-height: #{variables.$m-height}; + --_--g-list-item-view-border-radius: var(--g-border-radius-m); + --_--g-list-item-view-padding-inline: var(--g-spacing-2); + --_--g-list-item-view-padding-block: var(--g-spacing-1); + + --_--g-list-item-view-controls-gap: var(--g-spacing-2); + --_--g-list-item-view-controls-size: #{variables.$s-height}; + --_--g-list-item-view-controls-border-radius: var(--g-border-radius-s); + --_--g-list-item-view-controls-icon-size: 16px; + } + + &_l { + --_--g-list-item-view-height: #{variables.$l-height}; + --_--g-list-item-view-border-radius: var(--g-border-radius-l); + --_--g-list-item-view-padding-inline: var(--g-spacing-2); + --_--g-list-item-view-padding-block: var(--g-spacing-2) + --_--g-list-item-view-controls-gap: var(--g-spacing-2); + --_--g-list-item-view-controls-size: #{variables.$m-height}; + --_--g-list-item-view-controls-border-radius: var(--g-border-radius-m); + --_--g-list-item-view-controls-icon-size: 16px; + } + + &_xl { + --_--g-list-item-view-height: #{variables.$xl-height}; + --_--g-list-item-view-border-radius: var(--g-border-radius-xl); + --_--g-list-item-view-padding-inline: var(--g-spacing-2); + --_--g-list-item-view-padding-block: var(--g-spacing-3); + + --_--g-list-item-view-controls-gap: var(--g-spacing-2); + --_--g-list-item-view-controls-size: #{variables.$l-height}; + --_--g-list-item-view-controls-border-radius: var(--g-border-radius-l); + --_--g-list-item-view-controls-icon-size: 16px; + + @include mixins.text-body-2; + } + } + + &_is-container { + --_--g-list-item-view-padding-inline: 0; + --_--g-list-item-view-padding-block: 0; + } + + &:hover { + --_--g-list-item-view-background: var(--_--g-list-item-view-background-hover); + } + + &_selected { + --_--g-list-item-view-background: var(--_--g-list-item-view-background-selected); + --_--g-list-item-view-background-hover: var( + --_--g-list-item-view-background-selected-hover + ); + } + + &_active { + --_--g-list-item-view-background: var(--_--g-list-item-view-background-hover); + } + + &_disabled { + &, + &:hover, + &:focus { + --_--g-list-item-view-background: var(--_--g-list-item-view-background-disabled); + --_--g-list-item-view-color: var(--_--g-list-item-view-color-disabled); + --_--g-list-item-view-color-description: var(--_--g-list-item-view-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(--_--g-list-item-view-controls-size); + height: var(--_--g-list-item-view-controls-size); + color: var(--g-color-base-brand); + } + + &__icon { + width: var(--_--g-list-item-view-controls-icon-size); + height: var(--_--g-list-item-view-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..73c3639d7 --- /dev/null +++ b/src/components/lab/ListItemView/ListItemView.tsx @@ -0,0 +1,201 @@ +/* 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; +}