@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
489 lines • 25.6 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades';
import { usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TreeService } from '../../services/tree-service.js';
import { Tree } from './tree.js';
const getChildren = (node) => node.children ?? [];
describe('Tree', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
});
afterEach(() => {
document.body.innerHTML = '';
});
const createTreeData = () => [
{
id: 1,
name: 'Root 1',
children: [
{ id: 11, name: 'Child 1-1' },
{ id: 12, name: 'Child 1-2' },
],
},
{ id: 2, name: 'Root 2' },
{
id: 3,
name: 'Root 3',
children: [{ id: 31, name: 'Child 3-1' }],
},
];
const createTestService = () => {
return new TreeService({
getChildren,
});
};
describe('rendering', () => {
it('should render the shade-tree custom element', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
expect(tree).not.toBeNull();
service[Symbol.dispose]();
});
});
it('should render only root items when nothing is expanded', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const items = tree?.querySelectorAll('shade-tree-item');
expect(items?.length).toBe(3);
service[Symbol.dispose]();
});
});
it('should render a tree container with correct role', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
expect(tree).not.toBeNull();
expect(tree?.getAttribute('role')).toBe('tree');
expect(tree?.getAttribute('aria-multiselectable')).toBe('true');
service[Symbol.dispose]();
});
});
it('should render items with role treeitem', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('[role="treeitem"]');
expect(treeItems?.length).toBe(3);
service[Symbol.dispose]();
});
});
it('should render icon when renderIcon is provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name), renderIcon: () => createComponent("span", { "data-testid": "icon" }, "icon") })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const icons = tree?.querySelectorAll('[data-testid="icon"]');
expect(icons?.length).toBe(3);
service[Symbol.dispose]();
});
});
it('should set data-variant attribute when variant is provided', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name), variant: "contained" })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
expect(tree?.getAttribute('data-variant')).toBe('contained');
service[Symbol.dispose]();
});
});
it('should set aria-expanded on items with children', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
expect(treeItems[0]?.getAttribute('aria-expanded')).toBe('false');
expect(treeItems[1]?.getAttribute('aria-expanded')).toBeNull();
expect(treeItems[2]?.getAttribute('aria-expanded')).toBe('false');
service[Symbol.dispose]();
});
});
it('should set aria-level on items', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
expect(treeItems[0]?.getAttribute('aria-level')).toBe('1');
expect(treeItems[1]?.getAttribute('aria-level')).toBe('1');
expect(treeItems[2]?.getAttribute('aria-level')).toBe('1');
service[Symbol.dispose]();
});
});
});
describe('focus management', () => {
it('should set focus on click', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
expect(service.hasFocus.getValue()).toBe(false);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
tree?.click();
expect(service.hasFocus.getValue()).toBe(true);
service[Symbol.dispose]();
});
});
it('should lose focus on click outside', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(createComponent, null,
createComponent("div", { "data-testid": "outside" }, "Outside"),
createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) }))),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
tree?.click();
expect(service.hasFocus.getValue()).toBe(true);
const outside = document.querySelector('[data-testid="outside"]');
outside?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(service.hasFocus.getValue()).toBe(false);
service[Symbol.dispose]();
});
});
it('should set focused item on item click', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
treeItems[1]?.click();
expect(service.focusedItem.getValue()).toBe(treeData[1]);
service[Symbol.dispose]();
});
});
it('should add focused CSS class to focused item', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
service.focusedItem.setValue(treeData[1]);
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
expect(treeItems[1]?.hasAttribute('data-focused')).toBe(true);
expect(treeItems[0]?.hasAttribute('data-focused')).toBe(false);
service[Symbol.dispose]();
});
});
});
describe('selection', () => {
it('should add selected CSS class to selected items', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
service.selection.setValue([treeData[0], treeData[2]]);
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
expect(treeItems[0]?.hasAttribute('data-selected')).toBe(true);
expect(treeItems[1]?.hasAttribute('data-selected')).toBe(false);
expect(treeItems[2]?.hasAttribute('data-selected')).toBe(true);
service[Symbol.dispose]();
});
});
it('should set aria-selected on selected items', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
service.selection.setValue([treeData[0]]);
await flushUpdates();
const tree = document.querySelector('shade-tree');
const treeItems = tree?.querySelectorAll('shade-tree-item');
expect(treeItems[0]?.getAttribute('aria-selected')).toBe('true');
expect(treeItems[1]?.getAttribute('aria-selected')).toBe('false');
service[Symbol.dispose]();
});
});
it('should call onSelectionChange when selection changes', async () => {
const onSelectionChange = vi.fn();
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name), onSelectionChange: onSelectionChange })),
});
await flushUpdates();
service.selection.setValue([treeData[0]]);
await flushUpdates();
expect(onSelectionChange).toHaveBeenCalledWith([treeData[0]]);
service[Symbol.dispose]();
});
});
});
describe('item spatial navigation attributes', () => {
it('should set data-spatial-nav-target on tree items', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (node) => createComponent("span", null, node.name) })),
});
await flushUpdates();
const items = document.querySelectorAll('shade-tree-item');
for (const item of items) {
expect(item.hasAttribute('data-spatial-nav-target')).toBe(true);
}
service[Symbol.dispose]();
});
});
it('should set tabIndex 0 on focused item and -1 on others', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.focusedItem.setValue(treeData[1]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (node) => createComponent("span", null, node.name) })),
});
await flushUpdates();
const items = document.querySelectorAll('shade-tree-item');
expect(items[0]?.tabIndex).toBe(-1);
expect(items[1]?.tabIndex).toBe(0);
service[Symbol.dispose]();
});
});
it('should sync focusedItem on item onfocus', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (node) => createComponent("span", null, node.name) })),
});
await flushUpdates();
const items = document.querySelectorAll('shade-tree-item');
items[1]?.dispatchEvent(new FocusEvent('focus'));
expect(service.focusedItem.getValue()).toEqual(treeData[1]);
expect(service.hasFocus.getValue()).toBe(true);
service[Symbol.dispose]();
});
});
});
describe('keyboard navigation', () => {
it('should not handle ArrowDown (delegated to spatial navigation)', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.hasFocus.setValue(true);
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.focusedItem.setValue(treeData[0]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(service.focusedItem.getValue()).toEqual(treeData[0]);
service[Symbol.dispose]();
});
});
it('should handle ArrowRight to expand a node', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.hasFocus.setValue(true);
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.focusedItem.setValue(treeData[0]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
expect(service.isExpanded(treeData[0])).toBe(true);
service[Symbol.dispose]();
});
});
it('should handle ArrowLeft to collapse an expanded node', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.hasFocus.setValue(true);
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.expand(treeData[0]);
service.focusedItem.setValue(treeData[0]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
expect(service.isExpanded(treeData[0])).toBe(false);
service[Symbol.dispose]();
});
});
it('should not handle keyboard when not focused', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.hasFocus.setValue(false);
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.focusedItem.setValue(treeData[0]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(service.focusedItem.getValue()).toEqual(treeData[0]);
service[Symbol.dispose]();
});
});
});
describe('keyboard listener cleanup', () => {
it('should remove keyboard listener when component is disconnected', async () => {
await usingAsync(createInjector(), async (injector) => {
const rootElement = document.getElementById('root');
const treeData = createTreeData();
const service = createTestService();
service.hasFocus.setValue(true);
service.rootItems.setValue(treeData);
service.updateFlattenedNodes();
service.focusedItem.setValue(treeData[0]);
initializeShadeRoot({
injector,
rootElement,
jsxElement: (createComponent(Tree, { rootItems: treeData, treeService: service, renderItem: (item) => createComponent("span", null, item.name) })),
});
await flushUpdates();
const tree = document.querySelector('shade-tree');
tree.remove();
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
expect(service.focusedItem.getValue()).toEqual(treeData[0]);
service[Symbol.dispose]();
});
});
});
});
//# sourceMappingURL=tree.spec.js.map