import invariant from 'tiny-invariant'; import { isElement, dispatch, isInputElement } from '@coldwired/utils'; import { dispatchAction } from '@coldwired/actions'; import { createPopper, Instance as Popper } from '@popperjs/core'; import { Combobox, Action, type State, type Option, type Hint, type Fetcher } from './combobox'; const ctrlBindings = !!navigator.userAgent.match(/Macintosh/); export type ComboboxUIOptions = { input: HTMLInputElement; selectedValueInput: HTMLInputElement; list: HTMLUListElement; item: HTMLTemplateElement; valueSlots?: HTMLInputElement[] | NodeListOf; allowsCustomValue?: boolean; limit?: number; hint?: HTMLElement; getHintText?: (hint: Hint) => string; }; export class ComboboxUI implements EventListenerObject { #combobox?: Combobox; #popper?: Popper; #interactingWithList = false; #mouseOverList = false; #isComposing = false; #input: HTMLInputElement; #selectedValueInput: HTMLInputElement; #valueSlots: HTMLInputElement[]; #list: HTMLUListElement; #item: HTMLTemplateElement; #hint?: HTMLElement; #getHintText = defaultGetHintText; #allowsCustomValue: boolean; #limit?: number; #selectedData: Option['data'] = null; constructor({ input, selectedValueInput, valueSlots, list, item, hint, getHintText, allowsCustomValue, limit }: ComboboxUIOptions) { this.#input = input; this.#selectedValueInput = selectedValueInput; this.#valueSlots = valueSlots ? Array.from(valueSlots) : []; this.#list = list; this.#item = item; this.#hint = hint; this.#getHintText = getHintText ?? defaultGetHintText; this.#allowsCustomValue = allowsCustomValue ?? false; this.#limit = limit; } init() { if (this.#list.dataset.url) { const fetcher = createFetcher(this.#list.dataset.url); this.#list.removeAttribute('data-url'); const selected: Option | null = this.#input.value ? { label: this.#input.value, value: this.#selectedValueInput.value } : null; this.#combobox = new Combobox({ options: fetcher, selected, allowsCustomValue: this.#allowsCustomValue, limit: this.#limit, render: (state) => this.render(state) }); } else { const selectedValue = this.#selectedValueInput.value; const options = JSON.parse( this.#list.dataset.options ?? '[]' ) as Option[]; const selected = options.find(({ value }) => value == selectedValue) ?? null; this.#list.removeAttribute('data-options'); this.#list.removeAttribute('data-selected'); this.#combobox = new Combobox({ options, selected, allowsCustomValue: this.#allowsCustomValue, limit: this.#limit, render: (state) => this.render(state) }); } this.#combobox.init(); this.#input.addEventListener('blur', this); this.#input.addEventListener('focus', this); this.#input.addEventListener('click', this); this.#input.addEventListener('input', this); this.#input.addEventListener('keydown', this); this.#list.addEventListener('mousedown', this); this.#list.addEventListener('mouseenter', this); this.#list.addEventListener('mouseleave', this); document.body.addEventListener('mouseup', this); } destroy() { this.#combobox?.destroy(); this.#popper?.destroy(); this.#input.removeEventListener('blur', this); this.#input.removeEventListener('focus', this); this.#input.removeEventListener('click', this); this.#input.removeEventListener('input', this); this.#input.removeEventListener('keydown', this); this.#list.removeEventListener('mousedown', this); this.#list.removeEventListener('mouseenter', this); this.#list.removeEventListener('mouseleave', this); document.body.removeEventListener('mouseup', this); } handleEvent(event: Event) { switch (event.type) { case 'input': this.onInputChange(event as InputEvent); break; case 'blur': this.onInputBlur(); break; case 'focus': this.onInputFocus(); break; case 'click': if (event.target == this.#input) { this.onInputClick(event as MouseEvent); } else { this.onListClick(event as MouseEvent); } break; case 'keydown': this.onKeydown(event as KeyboardEvent); break; case 'mousedown': this.onListMouseDown(); break; case 'mouseenter': this.onListMouseEnter(); break; case 'mouseleave': this.onListMouseLeave(); break; case 'mouseup': this.onBodyMouseUp(event); break; case 'compositionstart': case 'compositionend': this.#isComposing = event.type == 'compositionstart'; break; } } private get combobox() { invariant(this.#combobox, 'ComboboxUI requires a Combobox instance'); return this.#combobox; } private render(state: State) { console.debug('combobox render', state); switch (state.action) { case Action.Select: case Action.Clear: this.renderSelect(state); break; } this.renderList(state); this.renderOptionList(state); this.renderValue(state); this.renderHintForScreenReader(state.hint); } private renderList(state: State): void { if (state.open) { if (!this.#list.hidden) return; this.#list.hidden = false; this.#list.classList.remove('hidden'); this.#list.addEventListener('click', this); this.#input.setAttribute('aria-expanded', 'true'); this.#input.addEventListener('compositionstart', this); this.#input.addEventListener('compositionend', this); this.#popper = createPopper(this.#input, this.#list, { placement: 'bottom-start' }); } else { if (this.#list.hidden) return; this.#list.hidden = true; this.#list.classList.add('hidden'); this.#list.removeEventListener('click', this); this.#input.setAttribute('aria-expanded', 'false'); this.#input.removeEventListener('compositionstart', this); this.#input.removeEventListener('compositionend', this); this.#popper?.destroy(); this.#interactingWithList = false; } } private renderValue(state: State): void { if (this.#input.value != state.inputValue) { this.#input.value = state.inputValue; } this.dispatchChange(() => { if (this.#selectedValueInput.value != state.inputValue) { if (state.allowsCustomValue || !state.inputValue) { this.#selectedValueInput.value = state.inputValue; } } return state.selection?.data; }); } private renderSelect(state: State): void { this.dispatchChange(() => { this.#selectedValueInput.value = state.selection?.value ?? ''; this.#input.value = state.selection?.label ?? ''; return state.selection?.data; }); } private renderOptionList(state: State): void { const html = state.options .map(({ label, value }) => { const fragment = this.#item.content.cloneNode(true) as DocumentFragment; const item = fragment.querySelector('li'); if (item) { item.id = optionId(value); item.setAttribute('data-turbo-force', 'server'); if (state.focused?.value == value) { item.setAttribute('aria-selected', 'true'); } else { item.removeAttribute('aria-selected'); } item.setAttribute('data-value', value); item.querySelector('slot[name="label"]')?.replaceWith(label); return item.outerHTML; } return ''; }) .join(''); dispatchAction({ targets: this.#list, action: 'update', fragment: html }); if (state.focused) { const id = optionId(state.focused.value); const item = this.#list.querySelector(`#${id}`); this.#input.setAttribute('aria-activedescendant', id); if (item) { scrollTo(this.#list, item); } } else { this.#input.removeAttribute('aria-activedescendant'); } } private renderHintForScreenReader(hint: Hint | null): void { if (this.#hint) { if (hint) { this.#hint.textContent = this.#getHintText(hint); } else { this.#hint.textContent = ''; } } } private dispatchChange(cb: () => Option['data']): void { const value = this.#selectedValueInput.value; const data = cb(); if (value != this.#selectedValueInput.value || data != this.#selectedData) { this.#selectedData = data; for (const input of this.#valueSlots) { switch (input.dataset.valueSlot) { case 'value': input.value = this.#selectedValueInput.value; break; case 'label': input.value = this.#input.value; break; case 'data:string': input.value = data ? String(data) : ''; break; case 'data': input.value = data ? JSON.stringify(data) : ''; break; } } console.debug('combobox change', this.#selectedValueInput.value); dispatch('change', { target: this.#selectedValueInput, detail: data ? { data } : undefined }); } } private onKeydown(event: KeyboardEvent): void { if (event.shiftKey || event.metaKey || event.altKey) return; if (!ctrlBindings && event.ctrlKey) return; if (this.#isComposing) return; if (this.combobox.keyboard(event.key)) { event.preventDefault(); event.stopPropagation(); } } private onInputClick(event: MouseEvent): void { const rect = this.#input.getBoundingClientRect(); const clickOnArrow = event.clientX >= rect.right - 40 && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; if (clickOnArrow) { this.combobox.toggle(); } } private onListClick(event: MouseEvent): void { if (isElement(event.target)) { const element = event.target.closest('[role="option"]'); if (element) { const value = element.getAttribute('data-value')?.trim(); if (value) { this.combobox.select(value); } } } } private onInputFocus(): void { this.combobox.focus(); } private onInputBlur(): void { if (!this.#interactingWithList) { this.combobox.close(); } } private onInputChange(event: InputEvent): void { if (isInputElement(event.target)) { this.combobox.input(event.target.value); } } private onListMouseDown(): void { this.#interactingWithList = true; } private onBodyMouseUp(event: Event): void { if ( this.#interactingWithList && !this.#mouseOverList && isElement(event.target) && event.target != this.#list && !this.#list.contains(event.target) ) { this.combobox.close(); } } private onListMouseEnter(): void { this.#mouseOverList = true; } private onListMouseLeave(): void { this.#mouseOverList = false; } } function scrollTo(container: HTMLElement, target: HTMLElement): void { if (!inViewport(container, target)) { container.scrollTop = target.offsetTop; } } function inViewport(container: HTMLElement, element: HTMLElement): boolean { const scrollTop = container.scrollTop; const containerBottom = scrollTop + container.clientHeight; const top = element.offsetTop; const bottom = top + element.clientHeight; return top >= scrollTop && bottom <= containerBottom; } function optionId(value: string) { return `option-${value .toLowerCase() // Replace spaces and special characters with underscores .replace(/[^a-z0-9]/g, '_') // Remove non-alphanumeric characters at start and end .replace(/^[^a-z]+|[^\w]$/g, '')}`; } function defaultGetHintText(hint: Hint): string { switch (hint.type) { case 'results': if (hint.label) { return `${hint.count} results. ${hint.label} is the top result: press Enter to activate.`; } return `${hint.count} results.`; case 'empty': return 'No results.'; case 'selected': return `${hint.label} selected.`; } } function createFetcher(source: string, param = 'q'): Fetcher { const url = new URL(source, location.href); const fetcher: Fetcher = (term: string, options) => { url.searchParams.set(param, term); return fetch(url.toString(), { headers: { accept: 'application/json' }, signal: options?.signal }).then((response) => { if (response.ok) { return response.json(); } return []; }); }; return async (term: string, options) => { await wait(500, options?.signal); return fetcher(term, options); }; } function wait(ms: number, signal?: AbortSignal) { return new Promise((resolve, reject) => { const abort = () => reject(new DOMException('Aborted', 'AbortError')); if (signal?.aborted) { abort(); } else { signal?.addEventListener('abort', abort); setTimeout(resolve, ms); } }); }