@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
567 lines • 29.3 kB
JavaScript
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