UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

489 lines 25.6 kB
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