diff --git a/src/components/Toc/Toc.scss b/src/components/Toc/Toc.scss new file mode 100644 index 0000000000..d7adfa1688 --- /dev/null +++ b/src/components/Toc/Toc.scss @@ -0,0 +1,17 @@ +@use '../variables'; + +$block: '.#{variables.$ns}toc'; + +#{$block} { + &__title { + font-size: var(--g-text-body-2-font-size); + font-weight: 500; + color: var(--g-color-text-primary); + margin-bottom: 12px; + } + + &__sections { + overflow-y: auto; + overflow-x: hidden; + } +} \ No newline at end of file diff --git a/src/components/Toc/Toc.tsx b/src/components/Toc/Toc.tsx new file mode 100644 index 0000000000..b23c8c942d --- /dev/null +++ b/src/components/Toc/Toc.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import type {QAProps} from '../types'; +import {block} from '../utils/cn'; + +import {TocItem} from './TocItem/TocItem'; +import type {TocItem as TocItemType} from './types'; + +import './Toc.scss'; + +const b = block('toc'); + +export interface TocProps extends QAProps { + className?: string; + value: string; + onUpdate: (value: string) => void; + items: (TocItemType & { + items?: TocItemType[]; + })[]; +} + +export const Toc = React.forwardRef(function Toc(props, ref) { + const {value: activeValue, items, className, onUpdate, qa} = props; + + return ( +
+
+ {items.map(({value, title, items: childrenItems}) => ( + + + {childrenItems?.map(({value: childrenValue, title: childrenTitle}) => ( + + ))} + + ))} +
+
+ ); +}); diff --git a/src/components/Toc/TocItem/TocItem.scss b/src/components/Toc/TocItem/TocItem.scss new file mode 100644 index 0000000000..880494870e --- /dev/null +++ b/src/components/Toc/TocItem/TocItem.scss @@ -0,0 +1,41 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}toc-item'; + +#{$block} { + $class: &; + + &__section { + cursor: pointer; + + & > #{$class}__section-link { + border-left-color: var(--g-color-line-generic); + } + + &-link { + 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); + } + } +} \ No newline at end of file diff --git a/src/components/Toc/TocItem/TocItem.tsx b/src/components/Toc/TocItem/TocItem.tsx new file mode 100644 index 0000000000..98d350d883 --- /dev/null +++ b/src/components/Toc/TocItem/TocItem.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import {block} from '../../utils/cn'; +import {useActionHandlers} from '../../utils/useActionHandlers'; +import type {TocItem as TocItemType} from '../types'; + +import './TocItem.scss'; + +const b = block('toc-item'); + +export interface TocItemProps extends TocItemType { + childItem?: boolean; + active?: boolean; + onClick: (value: string) => void; +} + +export const TocItem = (props: TocItemProps) => { + const {childItem = false, active = false, onClick, title, value} = props; + + const handleClick = () => onClick(value); + + const {onKeyDown} = useActionHandlers(handleClick); + + return ( +
+
+ {title} +
+
+ ); +}; diff --git a/src/components/Toc/__stories__/Toc.stories.scss b/src/components/Toc/__stories__/Toc.stories.scss new file mode 100644 index 0000000000..5c3239cb3f --- /dev/null +++ b/src/components/Toc/__stories__/Toc.stories.scss @@ -0,0 +1,9 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}toc-stories'; + +#{$block} { + $class: &; + + max-width: 156px; +} \ No newline at end of file diff --git a/src/components/Toc/__stories__/Toc.stories.tsx b/src/components/Toc/__stories__/Toc.stories.tsx new file mode 100644 index 0000000000..5ee29c6460 --- /dev/null +++ b/src/components/Toc/__stories__/Toc.stories.tsx @@ -0,0 +1,55 @@ +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 = (args) => { + const [active, setActive] = React.useState('control'); + + return setActive(value)} />; +}; + +export const Default = DefaultTemplate.bind({}); +Default.args = { + items: [ + { + value: 'vm', + title: 'Virtual machine creation', + }, + { + value: 'info', + title: 'Getting information about a group of virtual machines', + }, + { + value: 'disk', + title: 'Disk', + items: [ + { + value: 'control', + title: 'Disk controls', + }, + { + value: 'snapshots', + title: 'Disk snapshots', + }, + ], + }, + { + value: 'images', + title: 'Images with preinstalled software', + }, + ], + className: b(), +}; diff --git a/src/components/Toc/__tests__/Toc.test.tsx b/src/components/Toc/__tests__/Toc.test.tsx new file mode 100644 index 0000000000..96a7bf37f1 --- /dev/null +++ b/src/components/Toc/__tests__/Toc.test.tsx @@ -0,0 +1,118 @@ +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', + title: 'First item', + items: [], + }, + { + value: 'secondItem', + title: 'Second item', + items: [], + }, + { + value: 'thirdItem', + title: 'Third item', + items: [ + { + value: 'firstChildItem', + title: 'First child item', + }, + { + value: 'secondChildItem', + title: 'Second child item', + }, + ], + }, + { + value: 'fourthItem', + title: 'Fourth item', + items: [], + }, +]; + +const defaultValue = defaultItems[2].items[0].value; +const defaultTitle = defaultItems[2].items[0].title; + +const qaId = 'toc-component'; + +describe('Toc', () => { + test('renders active item correctly', () => { + const onUpdateFn = jest.fn(); + + render(); + const activeItem = screen.getByText(defaultTitle); + + expect(activeItem).toHaveAttribute('aria-checked', 'true'); + }); + + test('calls onUpdate with correct value', async () => { + const nextValue = defaultItems[0].value; + const nextTitle = defaultItems[0].title; + const onUpdateFn = jest.fn(); + const user = userEvent.setup(); + + render(); + const nextItem = screen.getByText(nextTitle); + await user.click(nextItem); + + expect(onUpdateFn).toBeCalledWith(nextValue); + }); + + test('accessible for keyboard', async () => { + const firstTitle = defaultItems[0].title; + const secondValue = defaultItems[1].value; + const onUpdateFn = jest.fn(); + const user = userEvent.setup(); + + render(); + 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( + , + ); + const component = screen.getByTestId(qaId); + + expect(component).toHaveClass(className); + }); + + test('use passed ref for component', () => { + const onUpdateFn = jest.fn(); + const ref = React.createRef(); + + render( + , + ); + const component = screen.getByTestId(qaId); + + expect(ref.current).toBe(component); + }); +}); diff --git a/src/components/Toc/index.ts b/src/components/Toc/index.ts new file mode 100644 index 0000000000..7c1f730f05 --- /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 0000000000..1128c94083 --- /dev/null +++ b/src/components/Toc/types.ts @@ -0,0 +1,5 @@ +export interface TocItem { + value: string; + title?: React.ReactNode; + selector?: string; +}