394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { OverlayContainer } from '@angular/cdk/overlay';
|
|
import { Component, signal } from '@angular/core';
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { Observable, Subject, of, throwError } from 'rxjs';
|
|
|
|
import { Autocomplete } from './autocomplete';
|
|
import { AutocompleteResolveValueFn, AutocompleteSearchFn } from './autocomplete.types';
|
|
|
|
interface LookupItem {
|
|
readonly code: string;
|
|
readonly title: string;
|
|
}
|
|
|
|
const INDIA: LookupItem = { code: 'IN', title: 'India' };
|
|
const INDONESIA: LookupItem = { code: 'ID', title: 'Indonesia' };
|
|
|
|
@Component({
|
|
standalone: true,
|
|
imports: [ReactiveFormsModule, Autocomplete],
|
|
template: `
|
|
<app-autocomplete
|
|
inputId="country"
|
|
label="Country"
|
|
[formControl]="control"
|
|
[searchFn]="searchFn"
|
|
[displayWith]="displayWith"
|
|
[valueWith]="valueWith"
|
|
placeholder="e.g.: USD"
|
|
[selectedItem]="selectedItem()"
|
|
[resolveValueFn]="resolveValueFn()"
|
|
[minSearchLength]="minLength"
|
|
[debounceTime]="delay()"
|
|
[readonly]="readonly()"
|
|
[showDropdownOnFocus]="openOnFocus()"
|
|
[submitAttempted]="submitAttempted()"
|
|
/>
|
|
`
|
|
})
|
|
class HostComponent {
|
|
readonly control = new FormControl<string | null>(null);
|
|
readonly selectedItem = signal<LookupItem | null>(null);
|
|
readonly resolveValueFn = signal<AutocompleteResolveValueFn<LookupItem, string> | null>(null);
|
|
searchFn: AutocompleteSearchFn<LookupItem> = () => of([INDIA, INDONESIA]);
|
|
readonly displayWith = (item: LookupItem): string => item.title;
|
|
readonly valueWith = (item: LookupItem): string => item.code;
|
|
minLength = 2;
|
|
readonly delay = signal(300);
|
|
readonly readonly = signal(false);
|
|
readonly openOnFocus = signal(false);
|
|
readonly submitAttempted = signal(false);
|
|
}
|
|
|
|
describe('Autocomplete', () => {
|
|
let fixture: ComponentFixture<HostComponent>;
|
|
let host: HostComponent;
|
|
let component: Autocomplete<LookupItem, string>;
|
|
let overlayContainer: OverlayContainer;
|
|
|
|
const input = (): HTMLInputElement => fixture.nativeElement.querySelector('input');
|
|
const type = (value: string): void => {
|
|
input().value = value;
|
|
input().dispatchEvent(new Event('input'));
|
|
fixture.detectChanges();
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents();
|
|
fixture = TestBed.createComponent(HostComponent);
|
|
host = fixture.componentInstance;
|
|
overlayContainer = TestBed.inject(OverlayContainer);
|
|
fixture.detectChanges();
|
|
component = fixture.debugElement.children[0].componentInstance;
|
|
});
|
|
|
|
afterEach(() => overlayContainer.ngOnDestroy());
|
|
|
|
it('initializes with an empty selection', () => {
|
|
expect(component.searchText()).toBe('');
|
|
expect(component.options()).toEqual([]);
|
|
expect(input().value).toBe('');
|
|
expect(input().placeholder).toBe('e.g.: USD');
|
|
expect(input().className).not.toContain('ti-form-control');
|
|
expect(input().className).toContain('placeholder:text-textmuted');
|
|
expect(input().className).toContain('dark:placeholder:text-white/50');
|
|
});
|
|
|
|
it('integrates with a reactive form control', () => {
|
|
component.select(INDIA);
|
|
expect(host.control.value).toBe('IN');
|
|
});
|
|
|
|
it('treats edited selected text as search text rather than a selected item', () => {
|
|
component.select(INDIA);
|
|
expect(component.activeItem()).toEqual(INDIA);
|
|
type('Ind');
|
|
expect(component.searchText()).toBe('Ind');
|
|
expect(component.activeItem()).toBeNull();
|
|
});
|
|
|
|
it('does not emit onChange from writeValue', () => {
|
|
const change = vi.fn();
|
|
component.registerOnChange(change);
|
|
component.writeValue('IN');
|
|
expect(change).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show invalid styling after touch before submit', () => {
|
|
host.control.setValidators(Validators.required);
|
|
host.control.updateValueAndValidity();
|
|
host.control.markAsTouched();
|
|
fixture.detectChanges();
|
|
expect(input().classList.contains('is-invalid')).toBe(false);
|
|
});
|
|
|
|
it('shows invalid styling after submit and removes it when valid', () => {
|
|
host.control.setValidators(Validators.required);
|
|
host.control.updateValueAndValidity();
|
|
host.submitAttempted.set(true);
|
|
fixture.detectChanges();
|
|
expect(input().classList.contains('is-invalid')).toBe(true);
|
|
expect(fixture.nativeElement.textContent).toContain('Country is required.');
|
|
|
|
host.control.setValue('IN');
|
|
fixture.detectChanges();
|
|
expect(input().classList.contains('is-invalid')).toBe(false);
|
|
expect(fixture.nativeElement.textContent).not.toContain('Country is required.');
|
|
});
|
|
|
|
it('applies a disabled form state', () => {
|
|
host.control.disable();
|
|
fixture.detectChanges();
|
|
expect(input().disabled).toBe(true);
|
|
});
|
|
|
|
it('waits for the configured debounce', () => {
|
|
vi.useFakeTimers();
|
|
const search = vi.fn(() => of([INDIA]));
|
|
host.searchFn = search;
|
|
fixture.detectChanges();
|
|
type('in');
|
|
vi.advanceTimersByTime(299);
|
|
expect(search).not.toHaveBeenCalled();
|
|
vi.advanceTimersByTime(1);
|
|
expect(search).toHaveBeenCalledWith('in', 10);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('does not search below the minimum length', () => {
|
|
vi.useFakeTimers();
|
|
const search = vi.fn(() => of([INDIA]));
|
|
host.searchFn = search;
|
|
fixture.detectChanges();
|
|
type('i');
|
|
vi.advanceTimersByTime(300);
|
|
expect(search).not.toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('loads five preview records on focus before the user types', () => {
|
|
vi.useFakeTimers();
|
|
const search = vi.fn(() => of([INDIA, INDONESIA]));
|
|
host.openOnFocus.set(true);
|
|
host.searchFn = search;
|
|
fixture.detectChanges();
|
|
input().focus();
|
|
vi.advanceTimersByTime(0);
|
|
fixture.detectChanges();
|
|
expect(search).toHaveBeenCalledWith('', 5);
|
|
expect(component.options()).toEqual([INDIA, INDONESIA]);
|
|
expect(component.showingPreview()).toBe(true);
|
|
expect(overlayContainer.getContainerElement().textContent).toContain('Type to search more...');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('replaces preview records with typed search results', () => {
|
|
vi.useFakeTimers();
|
|
host.openOnFocus.set(true);
|
|
host.delay.set(0);
|
|
host.searchFn = (term, _limit) => of(term ? [INDONESIA] : [INDIA]);
|
|
fixture.detectChanges();
|
|
input().focus();
|
|
vi.advanceTimersByTime(0);
|
|
expect(component.options()).toEqual([INDIA]);
|
|
type('in');
|
|
vi.advanceTimersByTime(0);
|
|
fixture.detectChanges();
|
|
expect(component.options()).toEqual([INDONESIA]);
|
|
expect(component.showingPreview()).toBe(false);
|
|
expect(overlayContainer.getContainerElement().textContent).not.toContain('Type to search more...');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('cancels stale search requests', () => {
|
|
vi.useFakeTimers();
|
|
const first = new Subject<readonly LookupItem[]>();
|
|
const second = new Subject<readonly LookupItem[]>();
|
|
host.delay.set(0);
|
|
host.searchFn = term => term === 'in' ? first : second;
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0);
|
|
type('ind'); vi.advanceTimersByTime(0);
|
|
first.next([INDONESIA]);
|
|
second.next([INDIA]);
|
|
expect(component.options()).toEqual([INDIA]);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('resets loading after a successful search', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0);
|
|
expect(component.loading()).toBe(false);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('resets loading after an error', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
host.searchFn = () => throwError(() => new Error('server'));
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0);
|
|
expect(component.loading()).toBe(false);
|
|
expect(component.error()).toBe('Unable to load results');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('recovers on a later search after an error', () => {
|
|
vi.useFakeTimers();
|
|
let attempts = 0;
|
|
host.delay.set(0);
|
|
host.searchFn = (): Observable<readonly LookupItem[]> =>
|
|
++attempts === 1 ? throwError(() => new Error('server')) : of([INDIA]);
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0);
|
|
type('ind'); vi.advanceTimersByTime(0);
|
|
expect(component.options()).toEqual([INDIA]);
|
|
expect(component.error()).toBeNull();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders returned options', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
|
|
expect(overlayContainer.getContainerElement().textContent).toContain('India');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('selects an option with the mouse', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
fixture.detectChanges();
|
|
type('in'); vi.advanceTimersByTime(0); fixture.detectChanges();
|
|
overlayContainer.getContainerElement().querySelector<HTMLButtonElement>('[role="option"]')?.click();
|
|
expect(host.control.value).toBe('IN');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('selects the highlighted option with Enter', () => {
|
|
component.options.set([INDIA]);
|
|
component.open();
|
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
|
expect(host.control.value).toBe('IN');
|
|
});
|
|
|
|
it('only highlights on arrow navigation', () => {
|
|
component.options.set([INDIA]);
|
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
|
|
expect(component.activeIndex()).toBe(0);
|
|
expect(host.control.value).toBeNull();
|
|
});
|
|
|
|
it('closes on Escape', () => {
|
|
component.open();
|
|
input().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
expect(component.isOpen()).toBe(false);
|
|
});
|
|
|
|
it('clears the form value to null', () => {
|
|
component.select(INDIA);
|
|
component.clear();
|
|
expect(host.control.value).toBeNull();
|
|
});
|
|
|
|
it('clears visible state when the form resets', () => {
|
|
component.select(INDIA);
|
|
host.control.reset();
|
|
fixture.detectChanges();
|
|
expect(component.searchText()).toBe('');
|
|
expect(component.activeItem()).toBeNull();
|
|
});
|
|
|
|
it('displays an existing selected item', () => {
|
|
host.selectedItem.set(INDIA);
|
|
host.control.setValue('IN');
|
|
fixture.detectChanges();
|
|
expect(component.searchText()).toBe('India');
|
|
});
|
|
|
|
it('does not resolve an empty string form value', () => {
|
|
const resolve = vi.fn(() => of(INDIA));
|
|
host.resolveValueFn.set(resolve);
|
|
fixture.detectChanges();
|
|
host.control.setValue('');
|
|
fixture.detectChanges();
|
|
expect(resolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses the matching selected item without resolving it again', () => {
|
|
const resolve = vi.fn(() => of(INDIA));
|
|
host.resolveValueFn.set(resolve);
|
|
host.selectedItem.set(INDIA);
|
|
fixture.detectChanges();
|
|
host.control.setValue('IN');
|
|
fixture.detectChanges();
|
|
expect(component.searchText()).toBe('India');
|
|
expect(resolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('resolves a non-empty value when no matching selected item is supplied', () => {
|
|
const resolve = vi.fn(() => of(INDIA));
|
|
host.resolveValueFn.set(resolve);
|
|
fixture.detectChanges();
|
|
host.control.setValue('IN');
|
|
fixture.detectChanges();
|
|
expect(resolve).toHaveBeenCalledWith('IN');
|
|
expect(component.searchText()).toBe('India');
|
|
});
|
|
|
|
it('does not retain a stale label when edit values change', () => {
|
|
host.selectedItem.set(INDIA);
|
|
host.control.setValue('IN');
|
|
fixture.detectChanges();
|
|
host.control.setValue('ID');
|
|
fixture.detectChanges();
|
|
expect(component.searchText()).toBe('');
|
|
});
|
|
|
|
it('closes when the overlay backdrop is clicked', () => {
|
|
component.open();
|
|
fixture.detectChanges();
|
|
overlayContainer.getContainerElement().querySelector<HTMLElement>('.cdk-overlay-backdrop')?.click();
|
|
expect(component.isOpen()).toBe(false);
|
|
});
|
|
|
|
it('prevents modification in read-only mode', () => {
|
|
host.readonly.set(true);
|
|
fixture.detectChanges();
|
|
component.select(INDIA);
|
|
expect(host.control.value).toBeNull();
|
|
});
|
|
|
|
it('prevents interaction while disabled', () => {
|
|
host.control.disable();
|
|
fixture.detectChanges();
|
|
component.open();
|
|
expect(component.isOpen()).toBe(false);
|
|
});
|
|
|
|
it('updates combobox ARIA state', () => {
|
|
component.open();
|
|
fixture.detectChanges();
|
|
expect(input().getAttribute('role')).toBe('combobox');
|
|
expect(input().getAttribute('aria-expanded')).toBe('true');
|
|
expect(input().getAttribute('aria-controls')).toBe('country-listbox');
|
|
});
|
|
|
|
it('renders the empty state', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
host.searchFn = () => of([]);
|
|
fixture.detectChanges();
|
|
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
|
|
expect(overlayContainer.getContainerElement().textContent).toContain('No results found');
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('renders the friendly error state', () => {
|
|
vi.useFakeTimers();
|
|
host.delay.set(0);
|
|
host.searchFn = () => throwError(() => new Error('raw error'));
|
|
fixture.detectChanges();
|
|
type('zz'); vi.advanceTimersByTime(0); fixture.detectChanges();
|
|
const text = overlayContainer.getContainerElement().textContent ?? '';
|
|
expect(text).toContain('Unable to load results');
|
|
expect(text).not.toContain('raw error');
|
|
vi.useRealTimers();
|
|
});
|
|
});
|