Repository: gravity-ui/uikit
Pull request #858 (2023-08-02 15:57:42)

feat(Toc): add component

gravity-ui-bot: [Preview](https://preview.gravity-ui.com/uikit/858/) is ready.
<!-- Sticky Pull Request Comment -->
amje: Also I'd use `<a>` in item for better a11y

Patch:
diff --git a/src/components/Toc/Toc.scss b/src/components/Toc/Toc.scss
new file mode 100644
index 00000000..ddb54701
--- /dev/null
+++ b/src/components/Toc/Toc.scss
@@ -0,0 +1,24 @@
+@use '../variables';
+@use '../../../styles/mixins.scss';
+
+$block: '.#{variables.$ns-new}toc';
+
+#{$block} {
+    &__title {
+        @include mixins.text-body-2();
+
+        color: var(--g-color-text-primary);
+        margin-bottom: 12px;
+    }
+
+    &__sections,
+    &__subsections {
+        padding: 0;
+        margin: 0;
+
+        overflow-y: auto;
+        overflow-x: hidden;
+
+        list-style: none;
+    }
+}
diff --git a/src/components/Toc/Toc.tsx b/src/components/Toc/Toc.tsx
new file mode 100644
index 00000000..0d16da47
--- /dev/null
+++ b/src/components/Toc/Toc.tsx
@@ -0,0 +1,62 @@
+import React from 'react';
+
+import type {QAProps} from '../types';
+import {blockNew} from '../utils/cn';
+
+import {TocItem} from './TocItem/TocItem';
+import type {TocItem as TocItemType} from './types';
+
+import './Toc.scss';
+
+const b = blockNew('toc');
+
+export interface TocProps extends QAProps {
+    className?: string;
+    value?: string;
+    onUpdate?: (value: string) => void;
+    items: TocItemType[];
+}
+
+export const Toc = React.forwardRef<HTMLElement, TocProps>(function Toc(props, ref) {
+    const {value: activeValue, items, className, onUpdate, qa} = props;
+
+    return (
+        <nav className={b(null, className)} ref={ref} data-qa={qa}>
+            <ul className={b('sections')}>
+                {items.map(({value, content, href, items: childrenItems}) => (
+                    <li key={value ?? href}>
+                        <TocItem
+                            content={content}
+                            value={value}
+                            href={href}
+                            active={activeValue === value}
+                            onClick={onUpdate}
+                        />
+                        {childrenItems?.length && (
+                            <ul className={b('subsections')}>
+                                {childrenItems?.map(
+                                    ({
+                                        value: childrenValue,
+                                        content: childrenContent,
+                                        href: childrenHref,
+                                    }) => (
+                                        <li key={childrenValue ?? childrenHref}>
+                                            <TocItem
+                                                content={childrenContent}
+                                                value={childrenValue}
+                                                href={childrenHref}
+                                                childItem={true}
+                                                active={activeValue === childrenValue}
+                                                onClick={onUpdate}
+                                            />
+                                        </li>
+                                    ),
+                                )}
+                            </ul>
+                        )}
+                    </li>
+                ))}
+            </ul>
+        </nav>
+    );
+});
diff --git a/src/components/Toc/TocItem/TocItem.scss b/src/components/Toc/TocItem/TocItem.scss
new file mode 100644
index 00000000..c3d975fa
--- /dev/null
+++ b/src/components/Toc/TocItem/TocItem.scss
@@ -0,0 +1,54 @@
+@use '../../variables';
+
+$block: '.#{variables.$ns-new}toc-item';
+
+#{$block} {
+    $class: &;
+
+    &__section {
+        cursor: pointer;
+
+        & > #{$class}__section-link {
+            border-left-color: var(--g-color-line-generic);
+        }
+
+        &-link {
+            &:focus {
+                outline: 2px solid var(--g-color-line-focus);
+                outline-offset: -2px;
+
+                &:focus-visible {
+                    border-radius: calc(var(--g-focus-border-radius) + 2px);
+                }
+            }
+
+            &:focus:not(:focus-visible) {
+                outline: none;
+            }
+
+            display: flex;
+            align-items: center;
+            padding: 6px 6px 6px 12px;
+            min-height: 28px;
+
+            color: var(--g-color-text-secondary);
+            border-left: 2px solid transparent;
+            text-decoration: none;
+
+            &:hover {
+                color: var(--g-color-text-complementary);
+            }
+        }
+
+        &_child {
+            #{$class}__section-link {
+                padding-left: 25px;
+            }
+        }
+
+        &_active > #{$class}__section-link {
+            color: var(--g-color-text-primary);
+            border-left-color: var(--g-color-line-brand);
+        }
+    }
+}
diff --git a/src/components/Toc/TocItem/TocItem.tsx b/src/components/Toc/TocItem/TocItem.tsx
new file mode 100644
index 00000000..f6b8406d
--- /dev/null
+++ b/src/components/Toc/TocItem/TocItem.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+
+import {blockNew} from '../../utils/cn';
+import {useActionHandlers} from '../../utils/useActionHandlers';
+import type {TocItem as TocItemType} from '../types';
+
+import './TocItem.scss';
+
+const b = blockNew('toc-item');
+
+export interface TocItemProps extends TocItemType {
+    childItem?: boolean;
+    active?: boolean;
+    onClick?: (value: string) => void;
+}
+
+export const TocItem = (props: TocItemProps) => {
+    const {active = false, childItem = false, content, href, value, onClick} = props;
+
+    const handleClick = React.useCallback(() => {
+        if (value === undefined || !onClick) {
+            return;
+        }
+
+        onClick(value);
+    }, [onClick, value]);
+
+    const {onKeyDown} = useActionHandlers(handleClick);
+
+    const item =
+        href === undefined ? (
+            <div
+                role="button"
+                tabIndex={0}
+                className={b('section-link')}
+                onClick={handleClick}
+                onKeyDown={onKeyDown}
+            >
+                {content}
+            </div>
+        ) : (
+            <a href={href} onClick={handleClick} className={b('section-link')}>
+                {content}
+            </a>
+        );
+
+    return <div className={b('section', {child: childItem, active})}>{item}</div>;
+};
diff --git a/src/components/Toc/__stories__/Toc.stories.scss b/src/components/Toc/__stories__/Toc.stories.scss
new file mode 100644
index 00000000..af9551ec
--- /dev/null
+++ b/src/components/Toc/__stories__/Toc.stories.scss
@@ -0,0 +1,9 @@
+@use '../../variables';
+
+$block: '.#{variables.$ns-new}toc-stories';
+
+#{$block} {
+    $class: &;
+
+    max-width: 156px;
+}
diff --git a/src/components/Toc/__stories__/Toc.stories.tsx b/src/components/Toc/__stories__/Toc.stories.tsx
new file mode 100644
index 00000000..57a58c9c
--- /dev/null
+++ b/src/components/Toc/__stories__/Toc.stories.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+
+import type {Meta, StoryFn} from '@storybook/react';
+
+import {block} from '../../utils/cn';
+import {Toc, TocProps} from '../Toc';
+
+import './Toc.stories.scss';
+
+const b = block('toc-stories');
+
+export default {
+    title: 'Components/Toc',
+    component: Toc,
+    argTypes: {},
+} as Meta;
+
+const DefaultTemplate: StoryFn<TocProps> = (args) => {
+    const [active, setActive] = React.useState('control');
+
+    return <Toc {...args} value={active} onUpdate={(value: string) => setActive(value)} />;
+};
+
+export const Default = DefaultTemplate.bind({});
+Default.args = {
+    items: [
+        {
+            value: 'vm',
+            content: 'Virtual machine creation',
+        },
+        {
+            value: 'info',
+            content: 'Getting information about a group of virtual machines',
+        },
+        {
+            value: 'disk',
+            content: 'Disk',
+            items: [
+                {
+                    value: 'control',
+                    content: 'Disk controls',
+                },
+                {
+                    value: 'snapshots',
+                    content: 'Disk snapshots',
+                },
+            ],
+        },
+        {
+            value: 'images',
+            content: 'Images with preinstalled software',
+        },
+    ],
+    className: b(),
+};
+
+const WithLinksTemplate: StoryFn<TocProps> = (args) => {
+    const [active, setActive] = React.useState('control');
+
+    return <Toc {...args} value={active} onUpdate={(value: string) => setActive(value)} />;
+};
+
+export const WithLinks = WithLinksTemplate.bind({});
+WithLinks.args = {
+    items: [
+        {
+            value: 'vm',
+            content: 'Virtual machine creation',
+            href: '#vm',
+        },
+        {
+            value: 'info',
+            content: 'Getting information about a group of virtual machines',
+            href: '#info',
+        },
+        {
+            value: 'disk',
+            content: 'Disk',
+            href: '#disk',
+            items: [
+                {
+                    value: 'control',
+                    content: 'Disk controls',
+                    href: '#control',
+                },
+                {
+                    value: 'snapshots',
+                    content: 'Disk snapshots',
+                    href: '#snapshots',
+                },
+            ],
+        },
+        {
+            value: 'images',
+            content: 'Images with preinstalled software',
+            href: '#images',
+        },
+    ],
+    className: b(),
+};
diff --git a/src/components/Toc/index.ts b/src/components/Toc/index.ts
new file mode 100644
index 00000000..7c1f730f
--- /dev/null
+++ b/src/components/Toc/index.ts
@@ -0,0 +1,2 @@
+export * from './Toc';
+export * from './types';
diff --git a/src/components/Toc/types.ts b/src/components/Toc/types.ts
new file mode 100644
index 00000000..f214b8fe
--- /dev/null
+++ b/src/components/Toc/types.ts
@@ -0,0 +1,6 @@
+export interface TocItem {
+    value?: string;
+    content?: React.ReactNode;
+    href?: string;
+    items?: TocItem[];
+}


Test patch:
diff --git a/src/components/Toc/__tests__/Toc.test.tsx b/src/components/Toc/__tests__/Toc.test.tsx
new file mode 100644
index 00000000..0b6460e0
--- /dev/null
+++ b/src/components/Toc/__tests__/Toc.test.tsx
@@ -0,0 +1,142 @@
+import React from 'react';
+
+import {render, screen} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import {Toc} from '../Toc';
+
+const defaultItems = [
+    {
+        value: 'firstItem',
+        content: 'First item',
+        items: [],
+    },
+    {
+        value: 'secondItem',
+        content: 'Second item',
+        items: [],
+    },
+    {
+        value: 'thirdItem',
+        content: 'Third item',
+        items: [
+            {
+                value: 'firstChildItem',
+                content: 'First child item',
+                items: [],
+            },
+            {
+                value: 'secondChildItem',
+                content: 'Second child item',
+                items: [],
+            },
+        ],
+    },
+    {
+        value: 'fourthItem',
+        content: 'Fourth item',
+        items: [],
+    },
+];
+
+const defaultValue = defaultItems[2].items[0].value;
+
+const itemsWithLinks = defaultItems.map((item) => ({...item, href: `#${item.value}`}));
+
+const defaultValueWithLink = itemsWithLinks[2].items[0].value;
+
+const qaId = 'toc-component';
+
+describe('Toc', () => {
+    test('calls onUpdate with correct value', async () => {
+        const nextValue = defaultItems[0].value;
+        const nextTitle = defaultItems[0].content;
+        const onUpdateFn = jest.fn();
+        const user = userEvent.setup();
+
+        render(<Toc value={defaultValue} items={defaultItems} onUpdate={onUpdateFn} />);
+        const nextItem = screen.getByText(nextTitle);
+        await user.click(nextItem);
+
+        expect(onUpdateFn).toBeCalledWith(nextValue);
+    });
+
+    test('calls onUpdate with correct item with link', async () => {
+        const nextValue = itemsWithLinks[0].value;
+        const nextTitle = itemsWithLinks[0].content;
+        const onUpdateFn = jest.fn();
+        const user = userEvent.setup();
+
+        render(<Toc value={defaultValueWithLink} items={itemsWithLinks} onUpdate={onUpdateFn} />);
+        const nextItem = screen.getByText(nextTitle);
+        await user.click(nextItem);
+
+        expect(onUpdateFn).toBeCalledWith(nextValue);
+    });
+
+    test('accessible for keyboard', async () => {
+        const firstTitle = defaultItems[0].content;
+        const secondValue = defaultItems[1].value;
+        const onUpdateFn = jest.fn();
+        const user = userEvent.setup();
+
+        render(<Toc value={defaultValue} items={defaultItems} onUpdate={onUpdateFn} />);
+        const firstItem = screen.getByText(firstTitle);
+        await user.click(firstItem);
+        await user.tab();
+        await user.keyboard('{Enter}');
+
+        expect(onUpdateFn).toBeCalledWith(secondValue);
+    });
+
+    test('accessible for keyboard with links', async () => {
+        const firstTitle = itemsWithLinks[0].content;
+        const secondValue = itemsWithLinks[1].value;
+        const onUpdateFn = jest.fn();
+        const user = userEvent.setup();
+
+        render(<Toc value={defaultValueWithLink} items={itemsWithLinks} onUpdate={onUpdateFn} />);
+        const firstItem = screen.getByText(firstTitle);
+        await user.click(firstItem);
+        await user.tab();
+        await user.keyboard('{Enter}');
+
+        expect(onUpdateFn).toBeCalledWith(secondValue);
+    });
+
+    test('add className', () => {
+        const className = 'my-class';
+        const onUpdateFn = jest.fn();
+
+        render(
+            <Toc
+                className={className}
+                value={defaultValue}
+                items={defaultItems}
+                onUpdate={onUpdateFn}
+                qa={qaId}
+            />,
+        );
+        const component = screen.getByTestId(qaId);
+
+        expect(component).toHaveClass(className);
+    });
+
+    test('use passed ref for component', () => {
+        const onUpdateFn = jest.fn();
+        const ref = React.createRef<HTMLDivElement>();
+
+        render(
+            <Toc
+                ref={ref}
+                value={defaultValue}
+                items={defaultItems}
+                onUpdate={onUpdateFn}
+                qa={qaId}
+            />,
+        );
+        const component = screen.getByTestId(qaId);
+
+        expect(ref.current).toBe(component);
+    });
+});