UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

282 lines 14.3 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 { ContextMenuManager } from './context-menu-manager.js'; import { ContextMenu } from './context-menu.js'; describe('ContextMenu', () => { beforeEach(() => { document.body.innerHTML = '<div id="root"></div>'; }); afterEach(() => { document.body.innerHTML = ''; }); const createTestItems = () => [ { type: 'item', data: { id: 1, name: 'Cut' }, label: 'Cut' }, { type: 'item', data: { id: 2, name: 'Copy' }, label: 'Copy' }, { type: 'separator' }, { type: 'item', data: { id: 3, name: 'Paste' }, label: 'Paste' }, ]; const renderContextMenu = async (options) => { const injector = createInjector(); const rootElement = document.getElementById('root'); const manager = new ContextMenuManager(); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(ContextMenu, { manager: manager, onItemSelect: options?.onItemSelect }), }); await flushUpdates(); return { injector, manager, getContextMenu: () => document.querySelector('shade-context-menu'), getMenu: () => document.querySelector('[role="menu"]'), getMenuItems: () => document.querySelectorAll('shade-context-menu-item'), getSeparators: () => document.querySelectorAll('[role="separator"]'), getBackdrop: () => document.querySelector('.context-menu-backdrop'), [Symbol.asyncDispose]: async () => { manager[Symbol.dispose](); await injector[Symbol.asyncDispose](); }, }; }; describe('rendering when closed', () => { it('should render the shade-context-menu custom element', async () => { await usingAsync(await renderContextMenu(), async ({ getContextMenu }) => { expect(getContextMenu()).not.toBeNull(); expect(getContextMenu().tagName.toLowerCase()).toBe('shade-context-menu'); }); }); it('should not render menu content when closed', async () => { await usingAsync(await renderContextMenu(), async ({ getMenu }) => { expect(getMenu()).toBeNull(); }); }); }); describe('rendering when opened', () => { it('should render the menu container when opened', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 100, y: 200 } }); await flushUpdates(); expect(getMenu()).not.toBeNull(); expect(getMenu().getAttribute('role')).toBe('menu'); }); }); it('should render menu items', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenuItems }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); expect(getMenuItems().length).toBe(3); }); }); it('should render separators', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getSeparators }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); expect(getSeparators().length).toBe(1); }); }); it('should render items with menuitem role', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenuItems }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); const items = getMenuItems(); items.forEach((item) => { expect(item.getAttribute('role')).toBe('menuitem'); }); }); }); it('should render a backdrop element', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getBackdrop }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); expect(getBackdrop()).not.toBeNull(); }); }); it('should position the menu at the specified coordinates', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 150, y: 250 } }); await flushUpdates(); const menu = getMenu(); expect(menu.style.left).toBe('150px'); expect(menu.style.top).toBe('250px'); }); }); }); describe('closing behavior', () => { it('should close when backdrop is clicked', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getBackdrop, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); expect(getMenu()).not.toBeNull(); getBackdrop().click(); await flushUpdates(); expect(manager.isOpened.getValue()).toBe(false); expect(getMenu()).toBeNull(); }); }); it('should close when Escape is pressed', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); expect(getMenu()).not.toBeNull(); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await flushUpdates(); expect(manager.isOpened.getValue()).toBe(false); expect(getMenu()).toBeNull(); }); }); it('should not close when clicking inside the menu container', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); const menu = getMenu(); menu.click(); await flushUpdates(); expect(manager.isOpened.getValue()).toBe(true); }); }); }); describe('item selection', () => { it('should call onItemSelect when an item is clicked', async () => { const onItemSelect = vi.fn(); await usingAsync(await renderContextMenu({ onItemSelect }), async ({ manager, getMenuItems }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); const items = getMenuItems(); items[1].click(); await flushUpdates(); expect(onItemSelect).toHaveBeenCalledWith({ id: 2, name: 'Copy' }); }); }); it('should close the menu after item selection', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenuItems, getMenu }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); const items = getMenuItems(); items[0].click(); await flushUpdates(); expect(manager.isOpened.getValue()).toBe(false); expect(getMenu()).toBeNull(); }); }); it('should call onItemSelect when Enter is pressed on focused item', async () => { const onItemSelect = vi.fn(); await usingAsync(await renderContextMenu({ onItemSelect }), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); manager.focusedIndex.setValue(1); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await flushUpdates(); expect(onItemSelect).toHaveBeenCalledWith({ id: 2, name: 'Copy' }); }); }); }); describe('keyboard navigation', () => { it('should move focus down with ArrowDown', async () => { await usingAsync(await renderContextMenu(), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(1); }); }); it('should move focus up with ArrowUp', async () => { await usingAsync(await renderContextMenu(), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); manager.focusedIndex.setValue(1); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(0); }); }); it('should skip separators during navigation', async () => { await usingAsync(await renderContextMenu(), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); manager.focusedIndex.setValue(1); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(3); }); }); it('should add focused class to the focused item', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenuItems }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); manager.focusedIndex.setValue(1); await flushUpdates(); const items = getMenuItems(); expect(items[0].hasAttribute('data-focused')).toBe(false); expect(items[1].hasAttribute('data-focused')).toBe(true); expect(items[2].hasAttribute('data-focused')).toBe(false); }); }); it('should move focus to Home', async () => { await usingAsync(await renderContextMenu(), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); manager.focusedIndex.setValue(3); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(0); }); }); it('should move focus to End', async () => { await usingAsync(await renderContextMenu(), async ({ manager }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(3); }); }); }); describe('disabled items', () => { it('should not select disabled items on click', async () => { const onItemSelect = vi.fn(); await usingAsync(await renderContextMenu({ onItemSelect }), async ({ manager, getMenuItems }) => { manager.open({ items: [ { type: 'item', data: { id: 1, name: 'Enabled' }, label: 'Enabled' }, { type: 'item', data: { id: 2, name: 'Disabled' }, label: 'Disabled', disabled: true }, ], position: { x: 0, y: 0 }, }); await flushUpdates(); const items = getMenuItems(); items[1].click(); await flushUpdates(); expect(onItemSelect).not.toHaveBeenCalled(); }); }); it('should set aria-disabled on disabled items', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getMenuItems }) => { manager.open({ items: [ { type: 'item', data: { id: 1, name: 'Enabled' }, label: 'Enabled' }, { type: 'item', data: { id: 2, name: 'Disabled' }, label: 'Disabled', disabled: true }, ], position: { x: 0, y: 0 }, }); await flushUpdates(); const items = getMenuItems(); expect(items[0].getAttribute('aria-disabled')).toBeNull(); expect(items[1].getAttribute('aria-disabled')).toBe('true'); }); }); }); describe('keyboard listener cleanup', () => { it('should remove keyboard listener when component is disconnected', async () => { await usingAsync(await renderContextMenu(), async ({ manager, getContextMenu }) => { manager.open({ items: createTestItems(), position: { x: 0, y: 0 } }); await flushUpdates(); const contextMenu = getContextMenu(); contextMenu.remove(); await flushUpdates(); manager.focusedIndex.setValue(0); window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(manager.focusedIndex.getValue()).toBe(0); }); }); }); }); //# sourceMappingURL=context-menu.spec.js.map