demarches-normaliennes/app/javascript/shared/combobox-ui.ts

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);
}
});
}