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, State, Action, Option, Hint } from './combobox'; const ctrlBindings = !!navigator.userAgent.match(/Macintosh/); export type ComboboxUIOptions = { input: HTMLInputElement; valueInput: HTMLInputElement; list: HTMLUListElement; item: HTMLTemplateElement; allowsCustomValue?: boolean; hint?: HTMLElement; getHintText?: (hint: Hint) => string; }; export class ComboboxUI implements EventListenerObject { #combobox?: Combobox; #popper?: Popper; #interactingWithList = false; #mouseOverList = false; #isComposing = false; #input: HTMLInputElement; #valueInput: HTMLInputElement; #list: HTMLUListElement; #item: HTMLTemplateElement; #hint?: HTMLElement; #getHintText = defaultGetHintText; #allowsCustomValue: boolean; constructor({ input, valueInput, list, item, hint, getHintText, allowsCustomValue }: ComboboxUIOptions) { this.#input = input; this.#valueInput = valueInput; this.#list = list; this.#item = item; this.#hint = hint; this.#getHintText = getHintText ?? defaultGetHintText; this.#allowsCustomValue = allowsCustomValue ?? false; } init() { const selectedValue = this.#list.dataset.selected; const options = JSON.parse(this.#list.dataset.options ?? '[]') as Option[]; this.#list.removeAttribute('data-options'); this.#list.removeAttribute('data-selected'); const selected = options.find(({ value }) => value == selectedValue) ?? null; this.#combobox = new Combobox({ options, selected, allowsCustomValue: this.#allowsCustomValue, value: this.#input.value, 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.#valueInput.value != state.inputValue) { if (state.allowsCustomValue || !state.inputValue) { this.#valueInput.value = state.inputValue; } } }); } private renderSelect(state: State): void { this.dispatchChange(() => { this.#valueInput.value = state.selection?.value ?? ''; this.#input.value = state.selection?.label ?? ''; }); } 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: () => void): void { const value = this.#valueInput.value; cb(); if (value != this.#valueInput.value) { console.debug('combobox change', this.#valueInput.value); dispatch('change', { target: this.#valueInput }); } } 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.replace(/\s/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.`; } }