From 79b06d3d6dec4c8f8040acbabf3c09b02c61fa20 Mon Sep 17 00:00:00 2001
From: Valerii Sidorenko <balepas@nebius.com>
Date: Tue, 21 Jan 2025 12:30:52 +0100
Subject: [PATCH] feat(Breadcrumbs): replace navigate with custom item
 component

---
 src/components/Breadcrumbs/BreadcrumbItem.tsx |  96 ------
 src/components/Breadcrumbs/Breadcrumbs.scss   |  37 ++-
 src/components/Breadcrumbs/Breadcrumbs.tsx    | 122 +++-----
 .../Breadcrumbs/BreadcrumbsDropdownMenu.tsx   | 106 +++++++
 .../Breadcrumbs/BreadcrumbsItemView.tsx       | 153 ++++++++++
 src/components/Breadcrumbs/README-ru.md       | 116 +++----
 src/components/Breadcrumbs/README.md          | 116 +++----
 .../__stories__/Breadcrumbs.stories.tsx       |  33 ++
 .../Breadcrumbs/__stories__/Docs.mdx          |   2 +
 .../__tests__/Breadcrumbs.test.tsx            |  49 ++-
 src/components/Breadcrumbs/index.ts           |   6 +-
 src/components/Breadcrumbs/utils.ts           |  21 --
 .../lab/ListItemView/ListItemView.scss        | 285 ++++++++++++++++++
 .../lab/ListItemView/ListItemView.tsx         | 201 ++++++++++++
 src/components/types.ts                       |  11 +-
 15 files changed, 1008 insertions(+), 346 deletions(-)
 delete mode 100644 src/components/Breadcrumbs/BreadcrumbItem.tsx
 create mode 100644 src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx
 create mode 100644 src/components/Breadcrumbs/BreadcrumbsItemView.tsx
 create mode 100644 src/components/lab/ListItemView/ListItemView.scss
 create mode 100644 src/components/lab/ListItemView/ListItemView.tsx

diff --git a/src/components/Breadcrumbs/BreadcrumbItem.tsx b/src/components/Breadcrumbs/BreadcrumbItem.tsx
deleted file mode 100644
index 78b71522cc..0000000000
--- 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<HTMLElement> = {
-        title,
-        onClick: handleAction,
-        'aria-disabled': isDisabled ? true : undefined,
-    };
-    if (Element === 'a') {
-        linkProps.href = props.href;
-        linkProps.hrefLang = props.hrefLang;
-        linkProps.target = props.target;
-        linkProps.rel = props.target === '_blank' && !props.rel ? 'noopener noreferrer' : props.rel;
-        linkProps.download = props.download;
-        linkProps.ping = props.ping;
-        linkProps.referrerPolicy = props.referrerPolicy;
-    } else {
-        linkProps.role = 'link';
-        linkProps.tabIndex = isDisabled ? undefined : 0;
-        linkProps.onKeyDown = (event: React.KeyboardEvent) => {
-            if (event.key === 'Enter') {
-                handleAction(event);
-            }
-        };
-    }
-
-    if (props.current) {
-        linkProps['aria-current'] = props['aria-current'] ?? 'page';
-    }
-
-    if (props.itemType === 'menu') {
-        linkProps = {};
-    }
-
-    return (
-        <Element
-            {...domProps}
-            {...linkProps}
-            className={
-                props.itemType === 'menu'
-                    ? b('menu')
-                    : b('link', {
-                          'is-current': props.current,
-                          'is-disabled': isDisabled && !props.current,
-                      })
-            }
-        >
-            {props.children}
-        </Element>
-    );
-}
-
-BreadcrumbItem.displayName = 'Breadcrumbs.Item';
diff --git a/src/components/Breadcrumbs/Breadcrumbs.scss b/src/components/Breadcrumbs/Breadcrumbs.scss
index 49530cbe40..5ee278ac00 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 b4858e00ff..83cd0dd181 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<T extends React.ElementType> =
+    React.ComponentPropsWithoutRef<T> & {
+        component?: T;
+        disabled?: boolean;
+    };
 
-function Item(_props: BreadcrumbsItemProps): React.ReactElement | null {
+function Item<T extends React.ElementType = 'a'>(
+    _props: BreadcrumbsItemProps<T>,
+): React.ReactElement | null {
     return null;
 }
 
-export interface BreadcrumbsProps extends DOMProps, AriaLabelingProps, QAProps {
+export interface BreadcrumbsProps<T extends React.ElementType = 'a'>
+    extends DOMProps,
+        AriaLabelingProps,
+        QAProps {
     id?: string;
     showRoot?: boolean;
     separator?: React.ReactNode;
     maxItems?: number;
     popupStyle?: 'staircase';
     popupPlacement?: PopupPlacement;
-    children: React.ReactElement<BreadcrumbsItemProps> | React.ReactElement<BreadcrumbsItemProps>[];
-    navigate?: (href: Href, routerOptions: RouterOptions | undefined) => void;
+    children:
+        | React.ReactElement<BreadcrumbsItemProps<T>>
+        | React.ReactElement<BreadcrumbsItemProps<T>>[];
     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 = (
-            <BreadcrumbItem itemType="menu">
-                <DropdownMenu
-                    items={hiddenItems.map((el, index) => {
-                        return {
-                            ...el.props,
-                            text: el.props.children,
-                            disabled: props.disabled,
-                            items: [],
-                            action: (event) => {
+            <BreadcrumbsDropdownMenu
+                disabled={props.disabled}
+                popupStyle={props.popupStyle}
+                component={BreadcrumbsDropdownMenu}
+            >
+                {hiddenItems.map((child, index) => {
+                    const Component = child.props.component ?? BreadcrumbsItemView;
+                    return (
+                        <Component
+                            {...child.props}
+                            index={index}
+                            key={child.key}
+                            disabled={props.disabled || child.props.disabled}
+                            onAction={() => {
                                 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}) => (
-                        <Button
-                            title={i18n('label_more')}
-                            className={b('more-button')}
-                            onClick={onClick}
-                            size="s"
-                            view="flat"
-                            disabled={props.disabled}
+                            }}
                         >
-                            <Button.Icon>...</Button.Icon>
-                        </Button>
-                    )}
-                />
-            </BreadcrumbItem>
+                            {child.props.children}
+                        </Component>
+                    );
+                })}
+            </BreadcrumbsDropdownMenu>
         );
 
         contents.push(menuItem);
@@ -233,18 +203,18 @@ export const Breadcrumbs = React.forwardRef(function Breadcrumbs(
             }
         };
 
+        const {component: Component = BreadcrumbsItemView, ...childProps} = child.props;
         return (
             <li key={index} className={b('item', {calculating: !calculated})}>
-                <BreadcrumbItem
-                    {...child.props}
+                <Component
+                    {...childProps}
                     key={key}
                     current={isCurrent}
-                    disabled={props.disabled || child.props.disabled}
+                    disabled={props.disabled || childProps.disabled}
                     onAction={handleAction}
-                    navigate={navigate}
                 >
-                    {child.props.children}
-                </BreadcrumbItem>
+                    {childProps.children}
+                </Component>
                 {isCurrent ? null : <BreadcrumbsSeparator separator={props.separator} />}
             </li>
         );
diff --git a/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx b/src/components/Breadcrumbs/BreadcrumbsDropdownMenu.tsx
new file mode 100644
index 0000000000..5116698002
--- /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<HTMLElement | null>};
+    popupStyle: undefined | 'staircase';
+}
+
+const menuContext = React.createContext<MenuContext>({
+    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<HTMLButtonElement | null>(null);
+    const [floating, setFloating] = React.useState<HTMLDivElement | null>(null);
+    const [activeIndex, setActiveIndex] = React.useState<number | null>(0);
+    const [open, setOpen] = React.useState(false);
+
+    const context = useFloatingRootContext({
+        open,
+        onOpenChange: setOpen,
+        elements: {reference, floating},
+    });
+
+    const listItemsRef = React.useRef<Array<HTMLElement | null>>([]);
+
+    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 (
+        <div className={b('menu')}>
+            <Button
+                ref={setReference}
+                {...getReferenceProps()}
+                title={i18n('label_more')}
+                aria-label={i18n('label_more')}
+                className={b('more-button')}
+                size="s"
+                view="flat"
+                disabled={disabled}
+            >
+                <Button.Icon>...</Button.Icon>
+            </Button>
+            <Popup
+                floatingContext={context}
+                floatingRef={setFloating}
+                floatingInteractions={interactions}
+                className={b('menu-popup')}
+                initialFocus={0}
+            >
+                <menuContext.Provider
+                    value={{isMenu: true, getItemProps, listItemsRef, activeIndex, popupStyle}}
+                >
+                    {children}
+                </menuContext.Provider>
+            </Popup>
+        </div>
+    );
+}
+
+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 0000000000..b3893022c9
--- /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<HTMLAnchorElement> {
+    children: React.ReactNode;
+    disabled?: boolean;
+    onAction?: () => void;
+    current?: boolean;
+    index?: number;
+}
+
+function BreadcrumbsItem(
+    props: BreadcrumbsItemViewProps,
+    ref: React.ForwardedRef<HTMLAnchorElement>,
+) {
+    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<HTMLElement>) => {
+        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<HTMLElement> = {
+        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 (
+            <ListItemView
+                {...getItemProps({
+                    ...restProps,
+                    ...domProps,
+                    ...linkProps,
+                    role: 'menuitem',
+                    active,
+                })}
+                ref={(node: HTMLElement | null) => {
+                    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}
+            </ListItemView>
+        );
+    }
+
+    return (
+        <Element
+            ref={ref}
+            {...restProps}
+            {...domProps}
+            {...linkProps}
+            className={b(
+                'link',
+                {
+                    'is-current': current,
+                    'is-disabled': isDisabled && !current,
+                },
+                props.className,
+            )}
+        >
+            {children}
+        </Element>
+    );
+}
+
+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 b8fc374918..169cecb2bc 100644
--- a/src/components/Breadcrumbs/README-ru.md
+++ b/src/components/Breadcrumbs/README-ru.md
@@ -336,92 +336,98 @@ LANDING_BLOCK-->
 
 ### Интеграция с роутерами
 
-Компонент `Breadcrumbs` принимает функцию навигации от роутера для программного управления навигацией на стороне клиента.
-На примере ниже показана общая схема:
+<!--/GITHUB_BLOCK-->
+
+#### 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 <BreadcrumbsItemView {...rest} href={href} onClick={onClick} />;
+}
 
+function Navigation() {
   return (
-    <header>
-      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item to="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item to="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item to="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </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 (
-    <header>
-      <Breadcrumbs navigate={history.push}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Link href={href} passHref legacyBehavior>
+      <BreadcrumbsItemView {...rest} />;
+    </Link>
   );
 }
-```
-
-#### React Router v6
-
-```jsx
-import {useNavigate} from 'react-router-dom';
-import {Breadcrumbs} from '@gravity-ui/uikit';
-
-function Header() {
-  const navigate = useNavigate();
 
+function Navigation() {
   return (
-    <header>
-      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item href="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </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 (
-    <header>
-      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item href="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </Breadcrumbs>
   );
 }
 ```
 
-`Pages router`
-
-```jsx
-import {useRouter} from 'next/router';
-import {Breadcrumbs} from '@gravity-ui/uikit';
+<!-- Storybook example -->
 
-function Header() {
-  const router = useRouter();
+<BreadcrumbsClientNavigation />
 
-  return (
-    <header>
-      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
-    </header>
-  );
-}
-```
+<!--/GITHUB_BLOCK-->
 
 ### Области навигации
 
diff --git a/src/components/Breadcrumbs/README.md b/src/components/Breadcrumbs/README.md
index 93b4db32dc..422de8b472 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.
+<!--GITHUB_BLOCK-->
+
+#### 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 <BreadcrumbsItemView {...rest} href={href} onClick={onClick} />;
+}
 
+function Navigation() {
   return (
-    <header>
-      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item to="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item to="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item to="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </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 (
-    <header>
-      <Breadcrumbs navigate={history.push}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Link href={href} passHref legacyBehavior>
+      <BreadcrumbsItemView {...rest} />;
+    </Link>
   );
 }
-```
-
-#### React Router v6
-
-```jsx
-import {useNavigate} from 'react-router-dom';
-import {Breadcrumbs} from '@gravity-ui/uikit';
-
-function Header() {
-  const navigate = useNavigate();
 
+function Navigation() {
   return (
-    <header>
-      <Breadcrumbs navigate={navigate}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item href="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </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 (
-    <header>
-      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
-    </header>
+    <Breadcrumbs>
+      <Breadcrumbs.Item href="/" component={RouterLink}>
+        Home
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components" component={RouterLink}>
+        Components
+      </Breadcrumbs.Item>
+      <Breadcrumbs.Item href="/components/breadcrumbs" component={RouterLink}>
+        Breadcrumbs
+      </Breadcrumbs.Item>
+    </Breadcrumbs>
   );
 }
 ```
 
-`Pages router`
-
-```jsx
-import {useRouter} from 'next/router';
-import {Breadcrumbs} from '@gravity-ui/uikit';
+<!-- Storybook example -->
 
-function Header() {
-  const router = useRouter();
+<BreadcrumbsClientNavigation />
 
-  return (
-    <header>
-      <Breadcrumbs navigate={router.push}>{/*...*/}</Breadcrumbs>
-    </header>
-  );
-}
-```
+<!--/GITHUB_BLOCK-->
 
 ### Landmarks
 
diff --git a/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx b/src/components/Breadcrumbs/__stories__/Breadcrumbs.stories.tsx
index b9d99dd73f..a5f1554805 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<typeof Breadcrumbs> = {
     title: 'Components/Navigation/Breadcrumbs',
@@ -152,3 +153,35 @@ export const DisabledItems = {
         </Breadcrumbs>
     ),
 } satisfies Story;
+
+export const ClientNavigation = {
+    render: (args) => {
+        return (
+            <Breadcrumbs {...args}>
+                <Breadcrumbs.Item to="/" component={RouterLink}>
+                    Home
+                </Breadcrumbs.Item>
+                <Breadcrumbs.Item to="/components" component={RouterLink}>
+                    Components
+                </Breadcrumbs.Item>
+                <Breadcrumbs.Item to="/components/breadcrumbs" component={RouterLink}>
+                    Breadcrumbs
+                </Breadcrumbs.Item>
+            </Breadcrumbs>
+        );
+    },
+} satisfies Story;
+
+function RouterLink({to, ...rest}: {to: string; children: React.ReactNode}) {
+    const href = to;
+    return (
+        <BreadcrumbsItemView
+            {...rest}
+            href={href}
+            onClick={(e) => {
+                e.preventDefault();
+                alert(`navigate to ${to}`);
+            }}
+        />
+    );
+}
diff --git a/src/components/Breadcrumbs/__stories__/Docs.mdx b/src/components/Breadcrumbs/__stories__/Docs.mdx
index ea094567fb..55add523f6 100644
--- a/src/components/Breadcrumbs/__stories__/Docs.mdx
+++ b/src/components/Breadcrumbs/__stories__/Docs.mdx
@@ -16,6 +16,7 @@ export const BreadcrumbsRootContext = () => <Canvas of={Stories.RootContext} sou
 export const BreadcrumbsSeparator = () => <Canvas of={Stories.Separator} sourceState="none" />;
 export const BreadcrumbsWithIcons = () => <Canvas of={Stories.WithIcons} sourceState="none" />;
 export const BreadcrumbsLandmarks = () => <Canvas of={Stories.Landmarks} sourceState="none" />;
+export const BreadcrumbsClientNavigation = () => <Canvas of={Stories.ClientNavigation} sourceState="none" />;
 export const BreadcrumbsDisabledItems = () => (
     <Canvas of={Stories.DisabledItems} sourceState="none" />
 );
@@ -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 f2da371ace..3703e5ba4a 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 (
+            <BreadcrumbsItemView
+                {...rest}
+                href={href}
+                onClick={() => navigate(href, routerOptions)}
+            />
+        );
+    }
     render(
-        <Breadcrumbs navigate={navigate}>
-            <Breadcrumbs.Item href="/" routerOptions={{foo: 'bar'} as any}>
+        <Breadcrumbs>
+            <Breadcrumbs.Item href="/" routerOptions={{foo: 'bar'}} component={RouterLink}>
                 Example.com
             </Breadcrumbs.Item>
-            <Breadcrumbs.Item href="/foo" routerOptions={{foo: 'foo'} as any}>
+            <Breadcrumbs.Item href="/foo" routerOptions={{foo: 'foo'}} component={RouterLink}>
                 Foo
             </Breadcrumbs.Item>
-            <Breadcrumbs.Item href="/foo/bar" routerOptions={{foo: 'bar'} as any}>
+            <Breadcrumbs.Item href="/foo/bar" routerOptions={{foo: 'bar'}} component={RouterLink}>
                 Bar
             </Breadcrumbs.Item>
-            <Breadcrumbs.Item href="/foo/bar/baz" routerOptions={{foo: 'bar'} as any}>
+            <Breadcrumbs.Item
+                href="/foo/bar/baz"
+                routerOptions={{foo: 'bar'}}
+                component={RouterLink}
+            >
                 Baz
             </Breadcrumbs.Item>
-            <Breadcrumbs.Item href="/foo/bar/baz/qux" routerOptions={{foo: 'bar'} as any}>
+            <Breadcrumbs.Item
+                href="/foo/bar/baz/qux"
+                routerOptions={{foo: 'bar'}}
+                component={RouterLink}
+            >
                 Qux
             </Breadcrumbs.Item>
         </Breadcrumbs>,
diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts
index ce977548b1..b51c2400b5 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 baa7b708f3..4e340bcedc 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 0000000000..35f4ee3110
--- /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 0000000000..73c3639d7f
--- /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<T extends React.ElementType = 'div'> 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<T> & Omit<React.ComponentPropsWithRef<T>, keyof ListItemViewProps<T>>,
+) => React.ReactElement;
+
+export function ListItemViewComponent(
+    props: ListItemViewProps & Omit<React.ComponentPropsWithoutRef<'div'>, keyof ListItemViewProps>,
+    ref: React.ForwardedRef<HTMLDivElement>,
+) {
+    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 (
+        <Component
+            ref={componentRef}
+            {...restProps}
+            {...filterDOMProps(props)}
+            className={b(
+                {
+                    size,
+                    selected: selected && selectionStyle === 'highlight',
+                    disabled,
+                    active,
+                    'is-container': isContainer,
+                    'has-description': Boolean(description),
+                },
+                className,
+            )}
+            style={style}
+            onClick={(e) => {
+                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 ? (
+                <Slot name="container">{children}</Slot>
+            ) : (
+                <ListItemViewContent {...props}>{children}</ListItemViewContent>
+            )}
+        </Component>
+    );
+}
+
+function ListItemViewContent({
+    selected,
+    disabled,
+    selectionStyle,
+    draggable,
+    nestedLevel,
+    collapsible,
+    collapsed,
+    onCollapseChange,
+    startContent,
+    children,
+    description,
+    endContent,
+}: ListItemViewProps) {
+    return (
+        <React.Fragment>
+            {draggable ? <Slot name="drag-handle" /> : null}
+            {nestedLevel ? (
+                <Slot name="spacer" style={{'--_--g-list-item-view-nested-level': nestedLevel}} />
+            ) : null}
+            {collapsible ? (
+                <Slot name="collapsed-toggle">
+                    <Button
+                        className={b('collapsible')}
+                        view="flat"
+                        tabIndex={-1}
+                        disabled={disabled}
+                        onClick={() => {
+                            onCollapseChange?.(!collapsed);
+                        }}
+                        aria-hidden="true"
+                    >
+                        <Button.Icon>
+                            <ArrowToggle
+                                className={b('arrow', {direction: collapsed ? 'bottom' : 'top'})}
+                            />
+                        </Button.Icon>
+                    </Button>
+                </Slot>
+            ) : null}
+            {selectionStyle === 'check' && (
+                <Slot name="checked">
+                    <div className={b('checked')}>
+                        {selected ? <Icon data={Check} className={b('icon')} /> : null}
+                    </div>
+                </Slot>
+            )}
+            {startContent ? <Slot name="start-content">{startContent}</Slot> : null}
+            <Slot name="content">{children}</Slot>
+            {description ? <Slot name="description">{description}</Slot> : null}
+            {endContent ? <Slot name="end-content">{endContent}</Slot> : null}
+        </React.Fragment>
+    );
+}
+
+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 (
+        <div className={b('slot', {name}, className)} style={style}>
+            {children}
+        </div>
+    );
+}
diff --git a/src/components/types.ts b/src/components/types.ts
index f592fbedda..2c30e002a7 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<ValueType extends string = string> 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<Target = Element> {
     /** Handler that is called when the element loses focus. */
     onBlur?: React.FocusEventHandler<Target>;
 }
+
+export interface CSSProperties extends React.CSSProperties {
+    [key: `--${string}`]: string | number;
+}