UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

420 lines (319 loc) 16.2 kB
import { createInjector } from '@furystack/inject' import { using, usingAsync } from '@furystack/utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SuggestManager } from './suggest-manager.js' import type { SuggestionResult } from './suggestion-result.js' type TestEntry = { id: number; name: string } const createTestEntries = (): TestEntry[] => [ { id: 1, name: 'alpha' }, { id: 2, name: 'beta' }, { id: 3, name: 'gamma' }, ] const createSuggestionResult = (entry: TestEntry): SuggestionResult => ({ element: { tagName: 'div', textContent: entry.name } as unknown as JSX.Element, score: entry.id, }) describe('SuggestManager', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) describe('Construction and disposal', () => { it('Should be constructed with getEntries and getSuggestionEntry functions', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { expect(manager.isOpened.getValue()).toBe(false) expect(manager.isLoading.getValue()).toBe(false) expect(manager.term.getValue()).toBe('') expect(manager.selectedIndex.getValue()).toBe(0) expect(manager.currentSuggestions.getValue()).toEqual([]) }) }) it('Should register keyboard and click listeners on construction', () => { const addEventListenerSpy = vi.spyOn(window, 'addEventListener') const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), () => { expect(addEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function), true) expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), true) }) addEventListenerSpy.mockRestore() }) it('Should dispose all observables and remove event listeners', () => { const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() const manager = new SuggestManager(getEntries, getSuggestionEntry) const isOpenedDisposeSpy = vi.spyOn(manager.isOpened, Symbol.dispose) const isLoadingDisposeSpy = vi.spyOn(manager.isLoading, Symbol.dispose) const termDisposeSpy = vi.spyOn(manager.term, Symbol.dispose) const selectedIndexDisposeSpy = vi.spyOn(manager.selectedIndex, Symbol.dispose) const currentSuggestionsDisposeSpy = vi.spyOn(manager.currentSuggestions, Symbol.dispose) manager[Symbol.dispose]() expect(isOpenedDisposeSpy).toHaveBeenCalled() expect(isLoadingDisposeSpy).toHaveBeenCalled() expect(termDisposeSpy).toHaveBeenCalled() expect(selectedIndexDisposeSpy).toHaveBeenCalled() expect(currentSuggestionsDisposeSpy).toHaveBeenCalled() expect(removeEventListenerSpy).toHaveBeenCalledWith('keyup', manager.keyPressListener, true) expect(removeEventListenerSpy).toHaveBeenCalledWith('click', manager.clickOutsideListener, true) removeEventListenerSpy.mockRestore() }) }) describe('Keyboard listener', () => { it('Should close suggestions when Escape is pressed', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { manager.isOpened.setValue(true) expect(manager.isOpened.getValue()).toBe(true) manager.keyPressListener({ key: 'Escape' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(false) }) }) it('Should not close suggestions for other keys', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { manager.isOpened.setValue(true) manager.keyPressListener({ key: 'Enter' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) manager.keyPressListener({ key: 'ArrowDown' } as KeyboardEvent) expect(manager.isOpened.getValue()).toBe(true) }) }) }) describe('Click-outside listener', () => { it('Should close suggestions when clicking outside the element', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { const element = document.createElement('div') element.setAttribute('data-testid', 'suggest') document.body.appendChild(element) manager.element = element manager.isOpened.setValue(true) const outsideElement = document.createElement('span') document.body.appendChild(outsideElement) manager.clickOutsideListener({ target: outsideElement } as unknown as MouseEvent) expect(manager.isOpened.getValue()).toBe(false) document.body.removeChild(element) document.body.removeChild(outsideElement) }) }) it('Should not close suggestions when clicking inside the element', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { const element = document.createElement('div') const childElement = document.createElement('span') element.appendChild(childElement) document.body.appendChild(element) manager.element = element manager.isOpened.setValue(true) manager.clickOutsideListener({ target: childElement } as unknown as MouseEvent) expect(manager.isOpened.getValue()).toBe(true) document.body.removeChild(element) }) }) it('Should not close when element is not set', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { manager.isOpened.setValue(true) const outsideElement = document.createElement('span') manager.clickOutsideListener({ target: outsideElement } as unknown as MouseEvent) expect(manager.isOpened.getValue()).toBe(true) }) }) it('Should not close when already closed', () => { const getEntries = vi.fn().mockResolvedValue([]) const getSuggestionEntry = vi.fn() using(new SuggestManager(getEntries, getSuggestionEntry), (manager) => { const element = document.createElement('div') document.body.appendChild(element) manager.element = element manager.isOpened.setValue(false) const outsideElement = document.createElement('span') document.body.appendChild(outsideElement) manager.clickOutsideListener({ target: outsideElement } as unknown as MouseEvent) expect(manager.isOpened.getValue()).toBe(false) document.body.removeChild(element) document.body.removeChild(outsideElement) }) }) }) describe('getSuggestion (debounced search)', () => { it('Should load suggestions after debounce delay', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) expect(getEntries).not.toHaveBeenCalled() expect(manager.isLoading.getValue()).toBe(false) await vi.advanceTimersByTimeAsync(250) expect(getEntries).toHaveBeenCalledWith('test') expect(manager.isOpened.getValue()).toBe(true) expect(manager.currentSuggestions.getValue()).toHaveLength(3) }) }) }) it('Should set isLoading while fetching', async () => { const testEntries = createTestEntries() let resolveEntries: (value: TestEntry[]) => void const entriesPromise = new Promise<TestEntry[]>((resolve) => { resolveEntries = resolve }) const getEntries = vi.fn().mockReturnValue(entriesPromise) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) expect(manager.isLoading.getValue()).toBe(true) resolveEntries!(testEntries) await vi.advanceTimersByTimeAsync(0) expect(manager.isLoading.getValue()).toBe(false) }) }) }) it('Should debounce multiple rapid calls', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'a' }) await vi.advanceTimersByTimeAsync(100) void manager.getSuggestion({ injector, term: 'ab' }) await vi.advanceTimersByTimeAsync(100) void manager.getSuggestion({ injector, term: 'abc' }) await vi.advanceTimersByTimeAsync(250) expect(getEntries).toHaveBeenCalledTimes(1) expect(getEntries).toHaveBeenCalledWith('abc') }) }) }) it('Should not fetch again if term is unchanged', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) expect(getEntries).toHaveBeenCalledTimes(1) void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) expect(getEntries).toHaveBeenCalledTimes(1) }) }) }) it('Should map entries to suggestions with getSuggestionEntry', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) expect(getSuggestionEntry).toHaveBeenCalledTimes(3) expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[0]) expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[1]) expect(getSuggestionEntry).toHaveBeenCalledWith(testEntries[2]) const suggestions = manager.currentSuggestions.getValue() expect(suggestions[0].entry).toBe(testEntries[0]) expect(suggestions[0].suggestion.score).toBe(1) }) }) }) it('Should preserve selected index when suggestion exists in new results', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) manager.selectedIndex.setValue(1) const newEntries = [testEntries[1], testEntries[2]] getEntries.mockResolvedValue(newEntries) getSuggestionEntry.mockImplementation(createSuggestionResult) void manager.getSuggestion({ injector, term: 'test2' }) await vi.advanceTimersByTimeAsync(250) expect(manager.selectedIndex.getValue()).toBe(0) }) }) }) it('Should reset selected index to 0 when selection not found in new results', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) manager.selectedIndex.setValue(2) const newEntries = [{ id: 4, name: 'delta' }] getEntries.mockResolvedValue(newEntries) void manager.getSuggestion({ injector, term: 'test2' }) await vi.advanceTimersByTimeAsync(250) expect(manager.selectedIndex.getValue()).toBe(0) }) }) }) }) describe('selectSuggestion', () => { it('Should emit onSelectSuggestion event with selected entry', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) const onSelect = vi.fn() manager.subscribe('onSelectSuggestion', onSelect) manager.selectSuggestion(1) expect(onSelect).toHaveBeenCalledWith(testEntries[1]) expect(manager.isOpened.getValue()).toBe(false) }) }) }) it('Should use current selectedIndex when no index provided', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) manager.selectedIndex.setValue(2) const onSelect = vi.fn() manager.subscribe('onSelectSuggestion', onSelect) manager.selectSuggestion() expect(onSelect).toHaveBeenCalledWith(testEntries[2]) }) }) }) it('Should close the suggestion list after selection', async () => { const testEntries = createTestEntries() const getEntries = vi.fn().mockResolvedValue(testEntries) const getSuggestionEntry = vi.fn().mockImplementation(createSuggestionResult) await usingAsync(createInjector(), async (injector) => { await usingAsync(new SuggestManager(getEntries, getSuggestionEntry), async (manager) => { void manager.getSuggestion({ injector, term: 'test' }) await vi.advanceTimersByTimeAsync(250) expect(manager.isOpened.getValue()).toBe(true) manager.selectSuggestion(0) expect(manager.isOpened.getValue()).toBe(false) }) }) }) }) })