diff --git a/app/javascript/shared/combobox-ui.ts b/app/javascript/shared/combobox-ui.ts new file mode 100644 index 000000000..e01c55450 --- /dev/null +++ b/app/javascript/shared/combobox-ui.ts @@ -0,0 +1,371 @@ +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.`; + } +}