UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

427 lines 21.2 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 { Menu } from './menu.js'; describe('Menu', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const createTestItems = () => [ { key: 'home', label: 'Home' }, { key: 'about', label: 'About' }, { type: 'divider' }, { key: 'settings', label: 'Settings' }, ]; const renderMenu = async (props) => { const injector = createInjector(); const root = document.getElementById('root'); initializeShadeRoot({ injector, rootElement: root, jsxElement: createComponent(Menu, { ...props }), }); await flushUpdates(); return { injector, menu: root.querySelector('shade-menu'), [Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](), }; }; describe('rendering', () => { it('should render a menu element', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { expect(menu).toBeTruthy(); expect(menu.tagName.toLowerCase()).toBe('shade-menu'); }); }); it('should render menu items', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { const items = menu.querySelectorAll('[role="menuitem"]'); expect(items.length).toBe(3); }); }); it('should render dividers', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { const dividers = menu.querySelectorAll('[role="separator"]'); expect(dividers.length).toBe(1); }); }); it('should render item labels', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { expect(menu.textContent).toContain('Home'); expect(menu.textContent).toContain('About'); expect(menu.textContent).toContain('Settings'); }); }); it('should render item icons when provided', async () => { const items = [{ key: 'home', label: 'Home', icon: createComponent("span", { className: "test-icon" }, "\uD83C\uDFE0") }]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { const icon = menu.querySelector('.menu-item-icon'); expect(icon).toBeTruthy(); expect(icon?.textContent).toContain('🏠'); }); }); }); describe('modes', () => { it('should default to vertical mode', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { expect(menu.getAttribute('data-mode')).toBe('vertical'); expect(menu.getAttribute('role')).toBe('menu'); }); }); it('should set horizontal mode', async () => { await usingAsync(await renderMenu({ items: createTestItems(), mode: 'horizontal' }), async ({ menu }) => { expect(menu.getAttribute('data-mode')).toBe('horizontal'); expect(menu.getAttribute('role')).toBe('menubar'); }); }); it('should set inline mode', async () => { await usingAsync(await renderMenu({ items: createTestItems(), mode: 'inline' }), async ({ menu }) => { expect(menu.getAttribute('data-mode')).toBe('inline'); expect(menu.getAttribute('role')).toBe('menu'); }); }); }); describe('selected state', () => { it('should mark the selected item', async () => { await usingAsync(await renderMenu({ items: createTestItems(), selectedKey: 'home' }), async ({ menu }) => { const selectedItem = menu.querySelector('[data-key="home"]'); expect(selectedItem?.classList.contains('selected')).toBe(true); }); }); it('should not mark unselected items', async () => { await usingAsync(await renderMenu({ items: createTestItems(), selectedKey: 'home' }), async ({ menu }) => { const aboutItem = menu.querySelector('[data-key="about"]'); expect(aboutItem?.classList.contains('selected')).toBe(false); }); }); }); describe('disabled items', () => { it('should mark disabled items with disabled class', async () => { const items = [{ key: 'disabled-item', label: 'Disabled', disabled: true }]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { const item = menu.querySelector('[data-key="disabled-item"]'); expect(item?.classList.contains('disabled')).toBe(true); }); }); it('should not call onSelect for disabled items', async () => { const handleSelect = vi.fn(); const items = [{ key: 'disabled-item', label: 'Disabled', disabled: true }]; await usingAsync(await renderMenu({ items, onSelect: handleSelect }), async ({ menu }) => { const item = menu.querySelector('[data-key="disabled-item"]'); item.click(); expect(handleSelect).not.toHaveBeenCalled(); }); }); }); describe('item selection', () => { it('should call onSelect when an item is clicked', async () => { const handleSelect = vi.fn(); await usingAsync(await renderMenu({ items: createTestItems(), onSelect: handleSelect }), async ({ menu }) => { const item = menu.querySelector('[data-key="home"]'); item.click(); expect(handleSelect).toHaveBeenCalledWith('home'); }); }); }); describe('groups', () => { it('should render groups with labels', async () => { const items = [ { type: 'group', key: 'group1', label: 'My Group', children: [ { key: 'a', label: 'Item A' }, { key: 'b', label: 'Item B' }, ], }, ]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { const group = menu.querySelector('[role="group"]'); expect(group).toBeTruthy(); expect(menu.textContent).toContain('My Group'); const groupItems = menu.querySelectorAll('[role="menuitem"]'); expect(groupItems.length).toBe(2); }); }); it('should render group children in vertical mode (always expanded)', async () => { const items = [ { type: 'group', key: 'group1', label: 'Group', children: [ { key: 'a', label: 'Item A' }, { key: 'b', label: 'Item B' }, ], }, ]; await usingAsync(await renderMenu({ items, mode: 'vertical' }), async ({ menu }) => { expect(menu.textContent).toContain('Item A'); expect(menu.textContent).toContain('Item B'); }); }); }); describe('inline mode groups', () => { it('should start with groups collapsed in inline mode', async () => { const items = [ { type: 'group', key: 'group1', label: 'Collapsible', children: [{ key: 'a', label: 'Hidden Item' }], }, ]; await usingAsync(await renderMenu({ items, mode: 'inline' }), async ({ menu }) => { const groupChildren = menu.querySelector('.menu-group-children'); expect(groupChildren).toBeTruthy(); expect(groupChildren.style.display).toBe('none'); }); }); it('should expand a group when its label is clicked', async () => { const items = [ { type: 'group', key: 'group1', label: 'Expandable', children: [{ key: 'a', label: 'Revealed Item' }], }, ]; await usingAsync(await renderMenu({ items, mode: 'inline' }), async ({ menu }) => { const groupLabel = menu.querySelector('.menu-group-label-inline'); expect(groupLabel).toBeTruthy(); groupLabel.click(); await flushUpdates(); const groupItems = menu.querySelectorAll('[role="menuitem"]'); expect(groupItems.length).toBe(1); expect(menu.textContent).toContain('Revealed Item'); }); }); }); describe('keyboard navigation', () => { it('should be focusable', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { expect(menu.getAttribute('tabindex')).toBe('0'); }); }); it('should navigate with ArrowDown in vertical mode', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem).toBeTruthy(); expect(focusedItem?.getAttribute('data-key')).toBe('home'); }); }); it('should navigate with ArrowRight in horizontal mode', async () => { await usingAsync(await renderMenu({ items: createTestItems(), mode: 'horizontal' }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem).toBeTruthy(); expect(focusedItem?.getAttribute('data-key')).toBe('home'); }); }); it('should select item with Enter key', async () => { const handleSelect = vi.fn(); await usingAsync(await renderMenu({ items: createTestItems(), onSelect: handleSelect }), async ({ menu }) => { menu.focus(); // Navigate to first item menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); // Press Enter menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(handleSelect).toHaveBeenCalledWith('home'); }); }); it('should navigate to last item with End key', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('settings'); }); }); it('should navigate to first item with Home key', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { menu.focus(); // Go to end first menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); // Then Home menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('home'); }); }); it('should wrap around when navigating past the last item', async () => { const items = [ { key: 'a', label: 'A' }, { key: 'b', label: 'B' }, ]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { menu.focus(); // Navigate to end menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); // One more down should wrap to first menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('a'); }); }); it('should wrap around when navigating before the first item', async () => { const items = [ { key: 'a', label: 'A' }, { key: 'b', label: 'B' }, ]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { menu.focus(); // Navigate to Home first menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); await flushUpdates(); // One more up should wrap to last menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('b'); }); }); it('should select item with Space key', async () => { const handleSelect = vi.fn(); await usingAsync(await renderMenu({ items: createTestItems(), onSelect: handleSelect }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); await flushUpdates(); expect(handleSelect).toHaveBeenCalledWith('home'); }); }); it('should navigate with ArrowUp in vertical mode', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { menu.focus(); // First go to end menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); // Then ArrowUp menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('about'); }); }); it('should navigate with ArrowLeft in horizontal mode', async () => { await usingAsync(await renderMenu({ items: createTestItems(), mode: 'horizontal' }), async ({ menu }) => { menu.focus(); // First go to end menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); await flushUpdates(); // Then ArrowLeft menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem?.getAttribute('data-key')).toBe('about'); }); }); it('should do nothing when pressing unrecognized key', async () => { const handleSelect = vi.fn(); await usingAsync(await renderMenu({ items: createTestItems(), onSelect: handleSelect }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); await flushUpdates(); expect(handleSelect).not.toHaveBeenCalled(); }); }); it('should not fire Enter/Space when no item is focused', async () => { const handleSelect = vi.fn(); await usingAsync(await renderMenu({ items: createTestItems(), onSelect: handleSelect }), async ({ menu }) => { menu.focus(); // Press Enter without navigating to any item first menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(handleSelect).not.toHaveBeenCalled(); }); }); it('should do nothing when there are no navigable items', async () => { const items = [{ type: 'divider' }]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { menu.focus(); menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flushUpdates(); const focusedItem = menu.querySelector('.menu-item.focused'); expect(focusedItem).toBeNull(); }); }); }); describe('mouse interaction', () => { it('should set focused state on mouseenter for non-disabled items', async () => { await usingAsync(await renderMenu({ items: createTestItems() }), async ({ menu }) => { const item = menu.querySelector('[data-key="about"]'); item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); await flushUpdates(); expect(item.classList.contains('focused')).toBe(true); }); }); it('should not set focused state on mouseenter for disabled items', async () => { const items = [{ key: 'disabled-item', label: 'Disabled', disabled: true }]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { const item = menu.querySelector('[data-key="disabled-item"]'); item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); await flushUpdates(); expect(item.classList.contains('focused')).toBe(false); }); }); }); describe('inline mode group toggle', () => { it('should collapse a previously expanded group when clicking its label again', async () => { const items = [ { type: 'group', key: 'group1', label: 'Collapsible', children: [{ key: 'a', label: 'Hidden Item' }], }, ]; await usingAsync(await renderMenu({ items, mode: 'inline' }), async ({ menu }) => { const groupLabel = menu.querySelector('.menu-group-label-inline'); // Expand groupLabel.click(); await flushUpdates(); const groupChildren = menu.querySelector('.menu-group-children'); expect(groupChildren.style.display).toBe(''); // Collapse groupLabel.click(); await flushUpdates(); expect(groupChildren.style.display).toBe('none'); }); }); }); describe('ARIA attributes', () => { it('should set aria-disabled on disabled items', async () => { const items = [{ key: 'disabled-item', label: 'Disabled', disabled: true }]; await usingAsync(await renderMenu({ items }), async ({ menu }) => { const item = menu.querySelector('[data-key="disabled-item"]'); expect(item?.getAttribute('aria-disabled')).toBe('true'); }); }); it('should set aria-current on selected item', async () => { await usingAsync(await renderMenu({ items: createTestItems(), selectedKey: 'home' }), async ({ menu }) => { const item = menu.querySelector('[data-key="home"]'); expect(item?.getAttribute('aria-current')).toBe('true'); }); }); }); }); //# sourceMappingURL=menu.spec.js.map