@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
326 lines • 16.3 kB
JavaScript
import { using } from '@furystack/utils';
import { describe, expect, it, vi } from 'vitest';
import { ContextMenuManager } from './context-menu-manager.js';
describe('ContextMenuManager', () => {
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' },
{ type: 'item', data: { id: 4, name: 'Delete' }, label: 'Delete', disabled: true },
];
describe('getNavigableIndices', () => {
it('should return indices of non-separator, non-disabled items', () => {
using(new ContextMenuManager(), (manager) => {
manager.items.setValue(createTestItems());
const indices = manager.getNavigableIndices();
expect(indices).toEqual([0, 1, 3]);
});
});
it('should return empty array when there are no navigable items', () => {
using(new ContextMenuManager(), (manager) => {
manager.items.setValue([
{ type: 'separator' },
{ type: 'item', data: { id: 1, name: 'Disabled' }, label: 'Disabled', disabled: true },
]);
const indices = manager.getNavigableIndices();
expect(indices).toEqual([]);
});
});
it('should return empty array for empty items', () => {
using(new ContextMenuManager(), (manager) => {
const indices = manager.getNavigableIndices();
expect(indices).toEqual([]);
});
});
});
describe('open', () => {
it('should set isOpened to true', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 100, y: 200 } });
expect(manager.isOpened.getValue()).toBe(true);
});
});
it('should set items when provided', () => {
using(new ContextMenuManager(), (manager) => {
const items = createTestItems();
manager.open({ items, position: { x: 0, y: 0 } });
expect(manager.items.getValue()).toEqual(items);
});
});
it('should set position when provided', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 150, y: 250 } });
expect(manager.position.getValue()).toEqual({ x: 150, y: 250 });
});
});
it('should focus the first navigable item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
expect(manager.focusedIndex.getValue()).toBe(0);
});
});
it('should focus the first navigable item when first items are separators', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({
items: [{ type: 'separator' }, { type: 'item', data: { id: 1, name: 'First' }, label: 'First' }],
position: { x: 0, y: 0 },
});
expect(manager.focusedIndex.getValue()).toBe(1);
});
});
it('should set focusedIndex to -1 when no navigable items exist', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({
items: [{ type: 'separator' }],
position: { x: 0, y: 0 },
});
expect(manager.focusedIndex.getValue()).toBe(-1);
});
});
it('should keep existing items when items option is not provided', () => {
using(new ContextMenuManager(), (manager) => {
const items = createTestItems();
manager.items.setValue(items);
manager.open({ position: { x: 50, y: 50 } });
expect(manager.items.getValue()).toEqual(items);
expect(manager.isOpened.getValue()).toBe(true);
});
});
it('should keep existing position when position option is not provided', () => {
using(new ContextMenuManager(), (manager) => {
manager.position.setValue({ x: 100, y: 200 });
manager.items.setValue(createTestItems());
manager.open();
expect(manager.position.getValue()).toEqual({ x: 100, y: 200 });
expect(manager.isOpened.getValue()).toBe(true);
});
});
});
describe('close', () => {
it('should set isOpened to false', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.close();
expect(manager.isOpened.getValue()).toBe(false);
});
});
it('should reset focusedIndex to -1', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.close();
expect(manager.focusedIndex.getValue()).toBe(-1);
});
});
});
describe('selectItem', () => {
it('should emit onSelectItem event with the item data', () => {
using(new ContextMenuManager(), (manager) => {
const items = createTestItems();
manager.open({ items, position: { x: 0, y: 0 } });
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.selectItem(1);
expect(onSelect).toHaveBeenCalledWith({ id: 2, name: 'Copy' });
});
});
it('should close the menu after selection', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.selectItem(0);
expect(manager.isOpened.getValue()).toBe(false);
});
});
it('should use the focused index when no index is provided', () => {
using(new ContextMenuManager(), (manager) => {
const items = createTestItems();
manager.open({ items, position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(3);
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.selectItem();
expect(onSelect).toHaveBeenCalledWith({ id: 3, name: 'Paste' });
});
});
it('should not emit when selecting a separator', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.selectItem(2);
expect(onSelect).not.toHaveBeenCalled();
});
});
it('should not emit when selecting a disabled item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.selectItem(4);
expect(onSelect).not.toHaveBeenCalled();
});
});
it('should not emit when item has no data', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({
items: [{ type: 'item', label: 'No data' }],
position: { x: 0, y: 0 },
});
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.selectItem(0);
expect(onSelect).not.toHaveBeenCalled();
});
});
});
describe('handleKeyDown', () => {
it('should not handle keyboard events when menu is closed', () => {
using(new ContextMenuManager(), (manager) => {
manager.items.setValue(createTestItems());
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(manager.focusedIndex.getValue()).toBe(0);
});
});
describe('ArrowDown', () => {
it('should move focus to the next navigable item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(manager.focusedIndex.getValue()).toBe(1);
});
});
it('should skip separators', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(1);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(manager.focusedIndex.getValue()).toBe(3);
});
});
it('should skip disabled items', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({
items: [
{ type: 'item', data: { id: 1, name: 'A' }, label: 'A' },
{ type: 'item', data: { id: 2, name: 'B' }, label: 'B', disabled: true },
{ type: 'item', data: { id: 3, name: 'C' }, label: 'C' },
],
position: { x: 0, y: 0 },
});
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(manager.focusedIndex.getValue()).toBe(2);
});
});
it('should wrap to first item when at the end', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(3);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(manager.focusedIndex.getValue()).toBe(0);
});
});
});
describe('ArrowUp', () => {
it('should move focus to the previous navigable item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(1);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(manager.focusedIndex.getValue()).toBe(0);
});
});
it('should skip separators', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(3);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(manager.focusedIndex.getValue()).toBe(1);
});
});
it('should wrap to last navigable item when at the beginning', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }));
expect(manager.focusedIndex.getValue()).toBe(3);
});
});
});
describe('Home', () => {
it('should move focus to the first navigable item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(3);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'Home' }));
expect(manager.focusedIndex.getValue()).toBe(0);
});
});
});
describe('End', () => {
it('should move focus to the last navigable item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'End' }));
expect(manager.focusedIndex.getValue()).toBe(3);
});
});
});
describe('Enter', () => {
it('should select the focused item', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.focusedIndex.setValue(1);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(onSelect).toHaveBeenCalledWith({ id: 2, name: 'Copy' });
});
});
it('should not select when no item is focused', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(-1);
const onSelect = vi.fn();
manager.subscribe('onSelectItem', onSelect);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(onSelect).not.toHaveBeenCalled();
});
});
it('should close the menu after selection', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.focusedIndex.setValue(0);
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }));
expect(manager.isOpened.getValue()).toBe(false);
});
});
});
describe('Escape', () => {
it('should close the menu', () => {
using(new ContextMenuManager(), (manager) => {
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.handleKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(manager.isOpened.getValue()).toBe(false);
expect(manager.focusedIndex.getValue()).toBe(-1);
});
});
});
});
describe('dispose', () => {
it('should dispose all observables without throwing', () => {
const manager = new ContextMenuManager();
expect(() => manager[Symbol.dispose]()).not.toThrow();
});
it('should dispose after open/close cycle', () => {
const manager = new ContextMenuManager();
manager.open({ items: createTestItems(), position: { x: 0, y: 0 } });
manager.close();
expect(() => manager[Symbol.dispose]()).not.toThrow();
});
});
});
//# sourceMappingURL=context-menu-manager.spec.js.map