@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
282 lines • 14.3 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 { 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