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