Skip to content

Commit

Permalink
feat(Toc): Toc component added
Browse files Browse the repository at this point in the history
  • Loading branch information
chelentos committed Sep 4, 2023
1 parent 641e424 commit 42a116f
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 0 deletions.
17 changes: 17 additions & 0 deletions src/components/Toc/Toc.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
51 changes: 51 additions & 0 deletions src/components/Toc/Toc.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, TocProps>(function Toc(props, ref) {
const {value: activeValue, items, className, onUpdate, qa} = props;

return (
<div className={b(null, className)} ref={ref} data-qa={qa}>
<div className={b('sections')}>
{items.map(({value, title, items: childrenItems}) => (
<React.Fragment key={value}>
<TocItem
title={title}
value={value}
active={activeValue === value}
onClick={onUpdate}
/>
{childrenItems?.map(({value: childrenValue, title: childrenTitle}) => (
<TocItem
key={childrenValue}
title={childrenTitle}
value={childrenValue}
childItem={true}
active={activeValue === childrenValue}
onClick={onUpdate}
/>
))}
</React.Fragment>
))}
</div>
</div>
);
});
41 changes: 41 additions & 0 deletions src/components/Toc/TocItem/TocItem.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
38 changes: 38 additions & 0 deletions src/components/Toc/TocItem/TocItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={b('section', {child: childItem, active})}>
<div
role="radio"
aria-checked={active}
tabIndex={0}
className={b('section-link')}
onClick={handleClick}
onKeyDown={onKeyDown}
>
{title}
</div>
</div>
);
};
9 changes: 9 additions & 0 deletions src/components/Toc/__stories__/Toc.stories.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@use '../../variables';

$block: '.#{variables.$ns}toc-stories';

#{$block} {
$class: &;

max-width: 156px;
}
55 changes: 55 additions & 0 deletions src/components/Toc/__stories__/Toc.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<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',
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(),
};
118 changes: 118 additions & 0 deletions src/components/Toc/__tests__/Toc.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Toc value={defaultValue} items={defaultItems} onUpdate={onUpdateFn} />);
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(<Toc value={defaultValue} items={defaultItems} onUpdate={onUpdateFn} />);
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(<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('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);
});
});
2 changes: 2 additions & 0 deletions src/components/Toc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Toc';
export * from './types';
5 changes: 5 additions & 0 deletions src/components/Toc/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface TocItem {
value: string;
title?: React.ReactNode;
selector?: string;
}

0 comments on commit 42a116f

Please sign in to comment.