UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

567 lines 29.3 kB
import { createInjector, Injector } 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 { CommandPalette } from './index.js'; describe('CommandPalette', () => { let originalAnimate; beforeEach(() => { vi.useFakeTimers(); document.body.innerHTML = '<div id="root"></div>'; originalAnimate = Element.prototype.animate; Element.prototype.animate = vi.fn(() => { const mockAnimation = { onfinish: null, oncancel: null, cancel: vi.fn(), play: vi.fn(), pause: vi.fn(), finish: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), }; return mockAnimation; }); }); afterEach(() => { vi.useRealTimers(); document.body.innerHTML = ''; Element.prototype.animate = originalAnimate; vi.restoreAllMocks(); }); const createMockProvider = (results) => { return vi.fn(async () => results); }; const createSuggestion = (text, score, onSelected = vi.fn()) => ({ element: createComponent("span", null, text), score, onSelected, }); describe('rendering', () => { it('should render the shade-command-palette custom element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); expect(commandPalette).not.toBeNull(); expect(commandPalette?.tagName.toLowerCase()).toBe('shade-command-palette'); }); }); it('should render the default prefix', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); expect(document.body.innerHTML).toContain('>'); }); }); it('should render input component', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const input = document.querySelector('shades-command-palette-input'); expect(input).not.toBeNull(); }); }); it('should render suggestion list component', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const suggestionList = document.querySelector('shade-command-palette-suggestion-list'); expect(suggestionList).not.toBeNull(); }); }); }); describe('keyboard navigation', () => { const triggerKeydown = (input, key) => { const event = new KeyboardEvent('keydown', { key, bubbles: true }); Object.defineProperty(event, 'target', { value: input, writable: false }); input.dispatchEvent(event); }; const triggerInput = (input) => { input.dispatchEvent(new Event('input', { bubbles: true })); }; const getSuggestionItems = (commandPalette) => { const suggestionList = commandPalette.querySelector('shade-command-palette-suggestion-list'); return suggestionList?.querySelectorAll('.suggestion-item') || []; }; it('should navigate down with ArrowDown key', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([ createSuggestion('Item 1', 100), createSuggestion('Item 2', 90), createSuggestion('Item 3', 80), ]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); // Open and trigger suggestions input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Press ArrowDown triggerKeydown(input, 'ArrowDown'); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems[1]?.classList.contains('selected')).toBe(true); }); }); it('should navigate up with ArrowUp key', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([ createSuggestion('Item 1', 100), createSuggestion('Item 2', 90), createSuggestion('Item 3', 80), ]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); // Open and trigger suggestions input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Navigate down then up triggerKeydown(input, 'ArrowDown'); await flushUpdates(); triggerKeydown(input, 'ArrowUp'); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems[0]?.classList.contains('selected')).toBe(true); }); }); it('should not navigate below the last item', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([createSuggestion('Item 1', 100), createSuggestion('Item 2', 90)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Press ArrowDown multiple times triggerKeydown(input, 'ArrowDown'); triggerKeydown(input, 'ArrowDown'); triggerKeydown(input, 'ArrowDown'); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems[1]?.classList.contains('selected')).toBe(true); }); }); it('should not navigate above the first item', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([createSuggestion('Item 1', 100), createSuggestion('Item 2', 90)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Press ArrowUp when already at first item triggerKeydown(input, 'ArrowUp'); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems[0]?.classList.contains('selected')).toBe(true); }); }); it('should select suggestion on Enter key', async () => { await usingAsync(createInjector(), async (injector) => { const onSelected = vi.fn(); const provider = createMockProvider([createSuggestion('Item 1', 100, onSelected)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Press Enter triggerKeydown(input, 'Enter'); await flushUpdates(); expect(onSelected).toHaveBeenCalledTimes(1); expect(onSelected).toHaveBeenCalledWith(expect.objectContaining({ injector: expect.any(Injector) })); }); }); }); describe('selection', () => { const triggerKeydown = (input, key) => { const event = new KeyboardEvent('keydown', { key, bubbles: true }); Object.defineProperty(event, 'target', { value: input, writable: false }); input.dispatchEvent(event); }; const triggerInput = (input) => { input.dispatchEvent(new Event('input', { bubbles: true })); }; const getSuggestionItems = (commandPalette) => { const suggestionList = commandPalette.querySelector('shade-command-palette-suggestion-list'); return suggestionList?.querySelectorAll('.suggestion-item') || []; }; it('should close palette when clicking a suggestion', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([createSuggestion('Item 1', 100), createSuggestion('Item 2', 90)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); // Open palette by clicking prefix const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); // Wait longer for the opened state to propagate expect(commandPalette.hasAttribute('data-opened')).toBe(true); const input = commandPalette.querySelector('input'); input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Click on first suggestion const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems.length).toBeGreaterThan(0); suggestionItems[0].click(); await flushUpdates(); // Clicking a suggestion should close the palette expect(commandPalette.hasAttribute('data-opened')).toBe(false); }); }); it('should close palette after selection', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([createSuggestion('Item 1', 100)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); // Open palette const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(true); const input = commandPalette.querySelector('input'); input.value = 'test'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); // Select via Enter triggerKeydown(input, 'Enter'); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(false); }); }); }); describe('command providers', () => { const triggerInput = (input) => { input.dispatchEvent(new Event('input', { bubbles: true })); }; const getSuggestionItems = (commandPalette) => { const suggestionList = commandPalette.querySelector('shade-command-palette-suggestion-list'); return suggestionList?.querySelectorAll('.suggestion-item') || []; }; it('should call all command providers when searching', async () => { await usingAsync(createInjector(), async (injector) => { const provider1 = createMockProvider([createSuggestion('Provider 1 Result', 100)]); const provider2 = createMockProvider([createSuggestion('Provider 2 Result', 90)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider1, provider2], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'search'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); expect(provider1).toHaveBeenCalled(); expect(provider2).toHaveBeenCalled(); }); }); it('should aggregate results from multiple providers', async () => { await usingAsync(createInjector(), async (injector) => { const provider1 = createMockProvider([createSuggestion('Provider 1 Result', 100)]); const provider2 = createMockProvider([createSuggestion('Provider 2 Result', 90)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider1, provider2], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'search'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems.length).toBe(2); expect(document.body.innerHTML).toContain('Provider 1 Result'); expect(document.body.innerHTML).toContain('Provider 2 Result'); }); }); it('should sort results by score', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([ createSuggestion('Low Score', 50), createSuggestion('High Score', 100), createSuggestion('Medium Score', 75), ]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'search'; triggerInput(input); await vi.advanceTimersByTimeAsync(300); await flushUpdates(); const suggestionItems = getSuggestionItems(commandPalette); expect(suggestionItems.length).toBe(3); // Results should be sorted by score ascending (sortBy sorts ascending) expect(suggestionItems[0]?.textContent).toContain('Low Score'); expect(suggestionItems[1]?.textContent).toContain('Medium Score'); expect(suggestionItems[2]?.textContent).toContain('High Score'); }); }); }); describe('opening and closing', () => { it('should open when clicking the prefix icon', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); expect(commandPalette.hasAttribute('data-opened')).toBe(false); const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(true); }); }); it('should close when clicking the close button', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); // Open first const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(true); // Close const closeButton = commandPalette.querySelector('.close-suggestions'); closeButton.click(); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(false); }); }); it('should add loading class when fetching suggestions', async () => { await usingAsync(createInjector(), async (injector) => { const provider = vi.fn(() => new Promise((resolve) => { setTimeout(() => resolve([createSuggestion('Result', 100)]), 100); })); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); input.value = 'test'; input.dispatchEvent(new Event('input', { bubbles: true })); await vi.advanceTimersByTimeAsync(260); await flushUpdates(); expect(commandPalette.hasAttribute('data-loading')).toBe(true); await vi.advanceTimersByTimeAsync(200); await flushUpdates(); expect(commandPalette.hasAttribute('data-loading')).toBe(false); }); }); }); describe('spatial navigation attributes', () => { it('should have data-spatial-nav-target on the host element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); expect(commandPalette.hasAttribute('data-spatial-nav-target')).toBe(true); }); }); it('should have tabIndex of -1 on the host element', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); expect(commandPalette.tabIndex).toBe(-1); }); }); it('should delegate focus to the inner input when the host is focused', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const input = commandPalette.querySelector('input'); commandPalette.dispatchEvent(new FocusEvent('focus', { bubbles: false })); await flushUpdates(); expect(document.activeElement).toBe(input); }); }); }); describe('click away', () => { it('should close when clicking outside the component', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: (createComponent("div", null, createComponent("div", { id: "outside" }, "Outside element"), createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">" }))), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); // Open first const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(true); // Click outside const outsideElement = document.getElementById('outside'); outsideElement.dispatchEvent(new MouseEvent('click', { bubbles: true })); await flushUpdates(); expect(commandPalette.hasAttribute('data-opened')).toBe(false); }); }); }); describe('styling', () => { it('should apply custom style to input container', async () => { await usingAsync(createInjector(), async (injector) => { const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [], defaultPrefix: ">", style: { maxWidth: '500px' } }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); const inputContainer = commandPalette.querySelector('.input-container'); expect(inputContainer.style.maxWidth).toBe('500px'); }); }); it('should pass fullScreenSuggestions to suggestion list', async () => { await usingAsync(createInjector(), async (injector) => { const provider = createMockProvider([createSuggestion('Item', 100)]); const rootElement = document.getElementById('root'); initializeShadeRoot({ injector, rootElement, jsxElement: createComponent(CommandPalette, { commandProviders: [provider], defaultPrefix: ">", fullScreenSuggestions: true }), }); await flushUpdates(); const commandPalette = document.querySelector('shade-command-palette'); // Open and search const termIcon = commandPalette.querySelector('.term-icon'); termIcon.click(); await flushUpdates(); const input = commandPalette.querySelector('input'); input.value = 'test'; input.dispatchEvent(new Event('input', { bubbles: true })); await flushUpdates(); const suggestionList = commandPalette.querySelector('shade-command-palette-suggestion-list'); const suggestionContainer = suggestionList.querySelector('.suggestion-items-container'); // fullScreenSuggestions sets left: '0' and specific width expect(suggestionContainer.style.left).toBe('0px'); }); }); }); }); //# sourceMappingURL=index.spec.js.map