Skip to content

Commit

Permalink
feat(Toc): render 6 levels of nested items
Browse files Browse the repository at this point in the history
  • Loading branch information
sofiushko committed Dec 23, 2024
1 parent 91e8cd3 commit cd3d86f
Show file tree
Hide file tree
Showing 10 changed files with 189 additions and 86 deletions.
23 changes: 0 additions & 23 deletions src/components/Toc/Toc.scss

This file was deleted.

52 changes: 10 additions & 42 deletions src/components/Toc/Toc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,31 @@ import React from 'react';
import type {QAProps} from '../types';
import {block} from '../utils/cn';

import {TocItem} from './TocItem/TocItem';
import {TocSections} from './TocSections';
import type {TocItem as TocItemType} from './types';

import './Toc.scss';

const b = block('toc');

export interface TocProps extends QAProps {
className?: string;
items: TocItemType[];
value?: string;
onUpdate?: (value: string) => void;
onItemClick?: (event: React.MouseEvent) => void;
}

export const Toc = React.forwardRef<HTMLElement, TocProps>(function Toc(props, ref) {
const {value: activeValue, items, className, onUpdate, qa} = props;
const {value: activeValue, items, className, onUpdate, qa, onItemClick} = 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} aria-current={activeValue === value}>
<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}
aria-current={activeValue === childrenValue}
>
<TocItem
content={childrenContent}
value={childrenValue}
href={childrenHref}
childItem={true}
active={activeValue === childrenValue}
onClick={onUpdate}
/>
</li>
),
)}
</ul>
)}
</li>
))}
</ul>
<TocSections
items={items}
value={activeValue}
onUpdate={onUpdate}
depth={1}
onItemClick={onItemClick}
/>
</nav>
);
});
10 changes: 7 additions & 3 deletions src/components/Toc/TocItem/TocItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ $block: '.#{variables.$ns}toc-item';
}
}

&_child {
#{$class}__section-link {
padding-inline-start: 25px;
@for $i from 1 through 6 {
$item-padding: 12px * $i;

&_depth_#{$i} {
#{$class}__section-link {
padding-inline-start: $item-padding;
}
}
}

Expand Down
32 changes: 23 additions & 9 deletions src/components/Toc/TocItem/TocItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,32 @@ export interface TocItemProps extends TocItemType {
childItem?: boolean;
active?: boolean;
onClick?: (value: string) => void;
depth: number;
onItemClick?: (event: React.MouseEvent) => void;
}

export const TocItem = (props: TocItemProps) => {
const {active = false, childItem = false, content, href, value, onClick} = props;
const {
active = false,
childItem = false,
content,
href,
value,
onClick,
onItemClick,
depth,
} = props;

const handleClick = React.useCallback(() => {
if (value === undefined || !onClick) {
return;
}

onClick(value);
}, [onClick, value]);
const handleClick = React.useCallback(
(event: React.MouseEvent) => {
onItemClick?.(event);
if (value === undefined || !onClick) {
return;
}
onClick(value);
},
[onClick, onItemClick, value],
);

const {onKeyDown} = useActionHandlers(handleClick);

Expand All @@ -46,5 +60,5 @@ export const TocItem = (props: TocItemProps) => {
</a>
);

return <div className={b('section', {child: childItem, active})}>{item}</div>;
return <div className={b('section', {child: childItem, depth, active})}>{item}</div>;
};
1 change: 1 addition & 0 deletions src/components/Toc/TocItem/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TocItem';
13 changes: 13 additions & 0 deletions src/components/Toc/TocSections/TocSections.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use '../../variables';
@use '../../../../styles/mixins.scss';

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

#{$block} {
padding: 0;
margin: 0;

overflow: hidden auto;

list-style: none;
}
55 changes: 55 additions & 0 deletions src/components/Toc/TocSections/TocSections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';

import {block} from '../../utils/cn';
import {TocItem} from '../TocItem';
import type {TocItem as TocItemType} from '../types';

import './TocSections.scss';

const b = block('toc-sections');

export interface TocSectionsProps {
items: TocItemType[];
value?: string;
onUpdate?: (value: string) => void;
depth?: number;
childItem?: boolean;
onItemClick?: (event: React.MouseEvent) => void;
}

export const TocSections = React.forwardRef<HTMLElement, TocSectionsProps>(function Toc(props) {
const {value: activeValue, items, onUpdate, childItem, depth = 1, onItemClick} = props;

if (depth > 6) {
return null;
}

return (
<ul className={b(null)}>
{items.map(({value, content, href, items: childrenItems}) => (
<li key={value ?? href} aria-current={activeValue === value}>
<TocItem
content={content}
value={value}
href={href}
active={activeValue === value}
onClick={onUpdate}
childItem={childItem}
depth={depth}
onItemClick={onItemClick}
/>
{childrenItems && childrenItems.length > 0 && (
<TocSections
items={childrenItems}
onUpdate={onUpdate}
childItem
depth={depth + 1}
value={activeValue}
onItemClick={onItemClick}
/>
)}
</li>
))}
</ul>
);
});
1 change: 1 addition & 0 deletions src/components/Toc/TocSections/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TocSections';
17 changes: 17 additions & 0 deletions src/components/Toc/__stories__/Toc.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,27 @@ Default.args = {
{
value: 'control',
content: 'Disk controls',
items: [
{
value: 'floppy',
content: 'Floppy',
},
{
value: 'hard',
content: 'Hard',
items: [],
},
],
},
{
value: 'snapshots',
content: 'Disk snapshots',
items: [
{
value: 'standard',
content: 'Standard',
},
],
},
],
},
Expand Down
71 changes: 62 additions & 9 deletions src/components/Toc/__tests__/Toc.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';

import {render, screen} from '../../../../test-utils/utils';
import {Toc} from '../Toc';
const titles = ['l1Title', 'l2Title', 'l3Title', 'l4Title', 'l5Title', 'l6Title', 'l7Title'];

const defaultItems = [
{
Expand All @@ -18,12 +19,42 @@ const defaultItems = [
},
{
value: 'thirdItem',
content: 'Third item',
content: titles[0],
items: [
{
value: 'firstChildItem',
content: 'First child item',
items: [],
content: titles[1],
items: [
{
value: 'firstChildItem-depth3',
content: titles[2],
items: [
{
value: 'firstChildItem-depth4',
content: titles[3],
items: [
{
value: 'firstChildItem-depth5',
content: titles[4],
items: [
{
value: 'firstChildItem-depth6',
content: titles[5],
items: [
{
value: 'firstChildItem-depth7',
content: titles[6],
items: [],
},
],
},
],
},
],
},
],
},
],
},
{
value: 'secondChildItem',
Expand Down Expand Up @@ -52,13 +83,22 @@ describe('Toc', () => {
const nextValue = defaultItems[0].value;
const nextTitle = defaultItems[0].content;
const onUpdateFn = jest.fn();
const onItemClickFn = jest.fn();
const user = userEvent.setup();

render(<Toc value={defaultValue} items={defaultItems} onUpdate={onUpdateFn} />);
render(
<Toc
value={defaultValue}
items={defaultItems}
onUpdate={onUpdateFn}
onItemClick={onItemClickFn}
/>,
);
const nextItem = screen.getByText(nextTitle);
await user.click(nextItem);

expect(onUpdateFn).toBeCalledWith(nextValue);
expect(onUpdateFn).toHaveBeenCalledWith(nextValue);
expect(onItemClickFn).toHaveBeenCalledWith(expect.objectContaining({}));
});

test('calls onUpdate with correct item with link', async () => {
Expand All @@ -71,13 +111,14 @@ describe('Toc', () => {
const nextItem = screen.getByText(nextTitle);
await user.click(nextItem);

expect(onUpdateFn).toBeCalledWith(nextValue);
expect(onUpdateFn).toHaveBeenCalledWith(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} />);
Expand All @@ -86,7 +127,7 @@ describe('Toc', () => {
await user.tab();
await user.keyboard('{Enter}');

expect(onUpdateFn).toBeCalledWith(secondValue);
expect(onUpdateFn).toHaveBeenCalledWith(secondValue);
});

test('accessible for keyboard with links', async () => {
Expand All @@ -101,7 +142,7 @@ describe('Toc', () => {
await user.tab();
await user.keyboard('{Enter}');

expect(onUpdateFn).toBeCalledWith(secondValue);
expect(onUpdateFn).toHaveBeenCalledWith(secondValue);
});

test('add className', () => {
Expand Down Expand Up @@ -148,6 +189,18 @@ describe('Toc', () => {

const currentItem = screen.getByRole('listitem', {current: true});

expect(currentItem.textContent).toContain(content);
expect(currentItem.textContent).toBe(content);
});

test('should render 6 levels', async () => {
const value = defaultItems[0].value;

render(<Toc value={value} items={defaultItems} qa={qaId} />);

for (let i = 0; i <= 5; i++) {
expect(screen.getByText(titles[i])).toBeVisible();
}

expect(screen.queryByText(titles[6])).not.toBeInTheDocument();
});
});

0 comments on commit cd3d86f

Please sign in to comment.