470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
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<HTMLInputElement>;
|
|
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<HTMLElement>(`#${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<HTMLElement>('[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<Option[]>((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);
|
|
}
|
|
});
|
|
}
|