diff --git a/src/components/Toc/Toc.scss b/src/components/Toc/Toc.scss new file mode 100644 index 0000000000..ddb5470104 --- /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 0000000000..0d16da47ee --- /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(function Toc(props, ref) { + const {value: activeValue, items, className, onUpdate, qa} = props; + + return ( + + ); +}); diff --git a/src/components/Toc/TocItem/TocItem.scss b/src/components/Toc/TocItem/TocItem.scss new file mode 100644 index 0000000000..c3d975fa6b --- /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 0000000000..f6b8406d0f --- /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 ? ( +
+ {content} +
+ ) : ( + + {content} + + ); + + return
{item}
; +}; diff --git a/src/components/Toc/__stories__/Toc.stories.scss b/src/components/Toc/__stories__/Toc.stories.scss new file mode 100644 index 0000000000..af9551ecce --- /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 0000000000..57a58c9ced --- /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 = (args) => { + const [active, setActive] = React.useState('control'); + + return 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 = (args) => { + const [active, setActive] = React.useState('control'); + + return 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/__tests__/Toc.test.tsx b/src/components/Toc/__tests__/Toc.test.tsx new file mode 100644 index 0000000000..0b6460e059 --- /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(); + 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(); + 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(); + 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(); + 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..f214b8fe17 --- /dev/null +++ b/src/components/Toc/types.ts @@ -0,0 +1,6 @@ +export interface TocItem { + value?: string; + content?: React.ReactNode; + href?: string; + items?: TocItem[]; +}