/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable react/prop-types */
import React, { useCallback, useMemo, useState } from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect } from 'chai';
import { useVirtualNavigationTree } from './use-virtual-navigation-tree';
import type { NavigationTreeData } from './use-virtual-navigation-tree';
function NavigationTreeItem({
id,
name,
level,
setSize,
posInSet,
isExpanded,
isTabbable,
}: {
id: string;
name: string;
level: number;
setSize: number;
posInSet: number;
isExpanded?: boolean;
isTabbable?: boolean;
}) {
return (
{name}
);
}
type MockItem = { id: string; items?: MockItem[] };
const items = [
{
id: 'Fruits',
items: [
{ id: 'Oranges' },
{ id: 'Pineapples' },
{ id: 'Apples', items: [{ id: 'Macintosh' }, { id: 'Granny Smith' }] },
{ id: 'Bananas' },
],
},
{
id: 'Vegetables',
items: [
{
id: 'Podded Vegetables',
items: [{ id: 'Lentil' }, { id: 'Pea' }, { id: 'Peanut' }],
},
{ id: 'Bulb and Stem Vegetables', items: [{ id: 'Asparagus' }] },
],
},
];
function normalizeItems(
items: MockItem[],
level = 1,
expanded = []
): NavigationTreeData {
return items
.map((item, index) =>
[
{
id: item.id,
name: item.id,
level,
setSize: items.length,
posInSet: index + 1,
...(item.items && { isExpanded: expanded.includes(item.id) }),
},
].concat(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
item.items && expanded.includes(item.id)
? normalizeItems(item.items, level + 1, expanded)
: []
)
)
.flat();
}
function NavigationTree({
activeItemId,
defaultExpanded = [],
onFocusMove = () => {},
}: {
activeItemId?: string;
defaultExpanded?: string[];
onFocusMove?: (item: NavigationTreeData[number]) => void;
}) {
const [expanded, setExpanded] = useState(defaultExpanded);
const onExpandedChange = useCallback(({ id }, isExpanded) => {
setExpanded((expanded) =>
isExpanded ? expanded.concat(id) : expanded.filter((_id) => _id !== id)
);
}, []);
const listItems = useMemo(() => {
return normalizeItems(items, 1, expanded);
}, [expanded]);
const [rootProps, currentTabbable] =
useVirtualNavigationTree({
items: listItems,
activeItemId,
onExpandedChange,
onFocusMove,
});
return (
{(listItems as any[]).map(
({ id, name, level, setSize, posInSet, isExpanded }) => {
return (
);
}
)}
);
}
describe('useRovingTabIndex', function () {
let originalRequestAnimationFrame;
before(function () {
originalRequestAnimationFrame = globalThis.requestAnimationFrame;
(globalThis as any).requestAnimationFrame = (fn) => fn();
});
after(function () {
(globalThis as any).requestAnimationFrame = originalRequestAnimationFrame;
});
afterEach(cleanup);
it('should make first element tabbable when no tabbableSelector provided', function () {
render();
expect(screen.getByText('Fruits')).to.have.attr('tabindex', '0');
});
it('should have only one element tabbable when rendered', function () {
render();
const tabbableElements = screen
.getAllByRole('treeitem')
.filter((el) => el.tabIndex === 0);
expect(tabbableElements).to.have.lengthOf(1);
});
describe('keyboard list navigation', function () {
it('should move focus to the next element on ArrowDown', function () {
render();
userEvent.tab();
userEvent.keyboard('{arrowdown}');
expect(screen.getByText('Vegetables')).to.eq(document.activeElement);
});
it('should move focus to the previous element on ArrowUp', function () {
render();
userEvent.tab();
userEvent.keyboard('{arrowup}');
expect(screen.getByText('Fruits')).to.eq(document.activeElement);
});
it('should move focus to the last element on End', function () {
render();
userEvent.tab();
userEvent.keyboard('{end}');
expect(screen.getByText('Vegetables')).to.eq(document.activeElement);
});
it('should move focus to the first element on Home', function () {
render();
userEvent.tab();
userEvent.keyboard('{home}');
expect(screen.getByText('Fruits')).to.eq(document.activeElement);
});
it('should move focus to the child treeitem on ArrowRight when expandable treeitem is expanded', function () {
render();
userEvent.tab();
userEvent.keyboard('{arrowright}');
expect(screen.getByText('Oranges')).to.eq(document.activeElement);
});
it('should move focus to the parent treeitem on ArrowLeft when expandable treeitem is collapsed', function () {
render(
);
userEvent.tab();
userEvent.keyboard('{arrowleft}');
expect(screen.getByText('Fruits')).to.eq(document.activeElement);
});
it('should move focus to the next item that starts with the pressed letter', function () {
render(
);
userEvent.tab();
userEvent.keyboard('{p}');
expect(screen.getByText('Podded Vegetables')).to.eq(
document.activeElement
);
userEvent.keyboard('{p}');
expect(screen.getByText('Pea')).to.eq(document.activeElement);
});
});
describe('keyboard expand / collapse', function () {
it('should expand collapsed treeitem on ArrowRight and keep the focus on the item', function () {
render();
userEvent.tab();
expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'false');
userEvent.keyboard('{arrowright}');
expect(screen.getByText('Fruits')).to.eq(document.activeElement);
expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'true');
});
it('should collapse expanded treeitem on ArrowLeft and keep the focus on the item', function () {
render();
userEvent.tab();
expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'true');
userEvent.keyboard('{arrowleft}');
expect(screen.getByText('Fruits')).to.eq(document.activeElement);
expect(screen.getByText('Fruits')).to.have.attr('aria-expanded', 'false');
});
it('should expand all set siblings of a focused element on * press', function () {
render(
);
userEvent.tab();
expect(screen.getByText('Podded Vegetables')).to.have.attr(
'aria-expanded',
'false'
);
expect(screen.getByText('Bulb and Stem Vegetables')).to.have.attr(
'aria-expanded',
'false'
);
userEvent.keyboard('*');
expect(screen.getByText('Podded Vegetables')).to.have.attr(
'aria-expanded',
'true'
);
expect(screen.getByText('Bulb and Stem Vegetables')).to.have.attr(
'aria-expanded',
'true'
);
// Checking that another level 2 treeitems are not expanded
expect(screen.getByText('Apples')).to.have.attr('aria-expanded', 'false');
});
});
describe('tabbableSelector', function () {
it('should make element that matches selector tabbable when `activeItemId` provided', function () {
render();
expect(screen.getByText('Fruits')).to.have.attr('tabindex', '-1');
expect(screen.getByText('Vegetables')).to.have.attr('tabindex', '0');
});
});
});