@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
388 lines • 19.5 kB
JavaScript
import { createInjector } from '@furystack/inject';
import { createComponent, flushUpdates, initializeShadeRoot } from '@furystack/shades';
import { sleepAsync, usingAsync } from '@furystack/utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Dropdown } from './dropdown.js';
describe('Dropdown', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div>';
});
afterEach(() => {
document.body.innerHTML = '';
});
const createTestItems = () => [
{ key: 'cut', label: 'Cut' },
{ key: 'copy', label: 'Copy' },
{ type: 'divider' },
{ key: 'paste', label: 'Paste' },
];
const renderDropdown = async (props, triggerText = 'Open') => {
const items = props.items ?? createTestItems();
const injector = createInjector();
const root = document.getElementById('root');
initializeShadeRoot({
injector,
rootElement: root,
jsxElement: (createComponent(Dropdown, { ...props, items: items },
createComponent("button", null, triggerText))),
});
await flushUpdates();
return {
injector,
dropdown: root.querySelector('shade-dropdown'),
[Symbol.asyncDispose]: () => injector[Symbol.asyncDispose](),
};
};
describe('rendering', () => {
it('should render the dropdown element', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
expect(dropdown).toBeTruthy();
expect(dropdown.tagName.toLowerCase()).toBe('shade-dropdown');
});
});
it('should render the trigger content', async () => {
await usingAsync(await renderDropdown({}, 'Click me'), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
expect(trigger).toBeTruthy();
expect(trigger?.textContent).toContain('Click me');
});
});
it('should not show the dropdown panel initially', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
expect(dropdown.hasAttribute('data-open')).toBe(false);
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel).toBeTruthy();
expect(panel?.classList.contains('visible')).toBe(false);
});
});
});
describe('opening and closing', () => {
it('should open when trigger is clicked', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel).toBeTruthy();
});
});
it('should show menu items when open', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const items = dropdown.querySelectorAll('[role="menuitem"]');
expect(items.length).toBe(3);
expect(dropdown.textContent).toContain('Cut');
expect(dropdown.textContent).toContain('Copy');
expect(dropdown.textContent).toContain('Paste');
});
});
it('should show dividers when open', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const dividers = dropdown.querySelectorAll('[role="separator"]');
expect(dividers.length).toBe(1);
});
});
it('should close when backdrop is clicked', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const backdrop = dropdown.querySelector('.dropdown-backdrop');
backdrop.click();
await flushUpdates();
expect(dropdown.hasAttribute('data-open')).toBe(false);
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel?.classList.contains('visible')).toBe(false);
});
});
it('should toggle when trigger is clicked twice', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
expect(dropdown.hasAttribute('data-open')).toBe(true);
trigger.click();
await flushUpdates();
expect(dropdown.hasAttribute('data-open')).toBe(false);
});
});
});
describe('item selection', () => {
it('should call onSelect when an item is clicked', async () => {
const handleSelect = vi.fn();
await usingAsync(await renderDropdown({ onSelect: handleSelect }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const item = dropdown.querySelector('[data-key="copy"]');
item.click();
await flushUpdates();
expect(handleSelect).toHaveBeenCalledWith('copy');
});
});
it('should close after selection', async () => {
await usingAsync(await renderDropdown({ onSelect: vi.fn() }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const item = dropdown.querySelector('[data-key="cut"]');
item.click();
await flushUpdates();
expect(dropdown.hasAttribute('data-open')).toBe(false);
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel?.classList.contains('visible')).toBe(false);
});
});
it('should not call onSelect for disabled items', async () => {
const handleSelect = vi.fn();
const items = [{ key: 'disabled', label: 'Disabled', disabled: true }];
await usingAsync(await renderDropdown({ items, onSelect: handleSelect }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const item = dropdown.querySelector('[data-key="disabled"]');
item.click();
await flushUpdates();
expect(handleSelect).not.toHaveBeenCalled();
});
});
});
describe('disabled state', () => {
it('should not open when disabled', async () => {
await usingAsync(await renderDropdown({ disabled: true }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
expect(dropdown.hasAttribute('data-open')).toBe(false);
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel?.classList.contains('visible')).toBe(false);
});
});
it('should mark trigger as disabled', async () => {
await usingAsync(await renderDropdown({ disabled: true }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
expect(trigger.classList.contains('disabled')).toBe(true);
});
});
it('should not mark trigger as disabled when not disabled', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
expect(trigger.classList.contains('disabled')).toBe(false);
});
});
});
describe('groups', () => {
it('should render groups in dropdown', async () => {
const items = [
{
type: 'group',
key: 'actions',
label: 'Actions',
children: [
{ key: 'save', label: 'Save' },
{ key: 'delete', label: 'Delete' },
],
},
];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const group = dropdown.querySelector('[role="group"]');
expect(group).toBeTruthy();
const menuItems = dropdown.querySelectorAll('[role="menuitem"]');
expect(menuItems.length).toBe(2);
});
});
});
describe('item icons', () => {
it('should render item icons', async () => {
const items = [{ key: 'cut', label: 'Cut', icon: createComponent("span", { className: "icon" }, "\u2702\uFE0F") }];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const icon = dropdown.querySelector('.dropdown-item-icon');
expect(icon).toBeTruthy();
expect(icon?.textContent).toContain('✂️');
});
});
it('should render JSX element icons', async () => {
const items = [{ key: 'cut', label: 'Cut', icon: createComponent("span", null, "X") }];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const icon = dropdown.querySelector('.dropdown-item-icon');
expect(icon).toBeTruthy();
expect(icon?.textContent).toContain('X');
});
});
});
describe('keyboard navigation', () => {
it('should close on Escape key', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
expect(dropdown.hasAttribute('data-open')).toBe(true);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await flushUpdates();
expect(dropdown.hasAttribute('data-open')).toBe(false);
});
});
it('should navigate to next item on ArrowDown', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flushUpdates();
const focusedItems = dropdown.querySelectorAll('.dropdown-item.focused');
expect(focusedItems.length).toBe(1);
});
});
it('should navigate to previous item on ArrowUp', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
// Move down twice then up once
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flushUpdates();
const focusedItems = dropdown.querySelectorAll('.dropdown-item.focused');
expect(focusedItems.length).toBe(1);
});
});
it('should select focused item on Enter', async () => {
const handleSelect = vi.fn();
await usingAsync(await renderDropdown({ onSelect: handleSelect }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
// Navigate down then press Enter
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flushUpdates();
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await flushUpdates();
expect(handleSelect).toHaveBeenCalled();
});
});
it('should wrap around when navigating past the last item', async () => {
const items = [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
// Navigate down past the end (2 items + 1 wrap)
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flushUpdates();
const focusedItems = dropdown.querySelectorAll('.dropdown-item.focused');
expect(focusedItems.length).toBe(1);
});
});
it('should wrap around when navigating before the first item', async () => {
const items = [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B' },
];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
await flushUpdates();
const focusedItems = dropdown.querySelectorAll('.dropdown-item.focused');
expect(focusedItems.length).toBe(1);
});
});
it('should not respond to keyboard when closed', async () => {
const handleSelect = vi.fn();
await usingAsync(await renderDropdown({ onSelect: handleSelect }), async ({ dropdown }) => {
// Don't open the dropdown
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await flushUpdates();
expect(handleSelect).not.toHaveBeenCalled();
expect(dropdown.hasAttribute('data-open')).toBe(false);
});
});
it('should skip disabled items during navigation', async () => {
const items = [
{ key: 'a', label: 'A' },
{ key: 'b', label: 'B', disabled: true },
{ key: 'c', label: 'C' },
];
await usingAsync(await renderDropdown({ items }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
await sleepAsync(50);
// Navigate to first enabled item
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
await flushUpdates();
const focusedItems = dropdown.querySelectorAll('.dropdown-item.focused');
expect(focusedItems.length).toBe(1);
// Focused item should not be the disabled one
expect(focusedItems[0].classList.contains('disabled')).toBe(false);
});
});
});
describe('spatial navigation', () => {
it('should have data-spatial-nav-passthrough on the backdrop', async () => {
await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
const backdrop = dropdown.querySelector('.dropdown-backdrop');
expect(backdrop.hasAttribute('data-spatial-nav-passthrough')).toBe(true);
});
});
});
describe('placement', () => {
it('should accept bottomRight placement', async () => {
await usingAsync(await renderDropdown({ placement: 'bottomRight' }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel).toBeTruthy();
});
});
it('should accept topLeft placement', async () => {
await usingAsync(await renderDropdown({ placement: 'topLeft' }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel).toBeTruthy();
});
});
it('should accept topRight placement', async () => {
await usingAsync(await renderDropdown({ placement: 'topRight' }), async ({ dropdown }) => {
const trigger = dropdown.querySelector('.dropdown-trigger');
trigger.click();
await flushUpdates();
const panel = dropdown.querySelector('.dropdown-panel');
expect(panel).toBeTruthy();
});
});
});
});
//# sourceMappingURL=dropdown.spec.js.map