UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

388 lines 19.5 kB
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