feat(combobox): implement Combobox
This commit is contained in:
parent
0454d2066e
commit
628bef562b
2 changed files with 530 additions and 0 deletions
265
app/javascript/shared/combobox.test.ts
Normal file
265
app/javascript/shared/combobox.test.ts
Normal file
|
@ -0,0 +1,265 @@
|
|||
import { suite, test, beforeEach, expect } from 'vitest';
|
||||
|
||||
import { Combobox, Option, State } from './combobox';
|
||||
|
||||
suite('Combobox', () => {
|
||||
const options: Option[] =
|
||||
'Fraises,Myrtilles,Framboises,Mûres,Canneberges,Groseilles,Baies de sureau,Mûres blanches,Baies de genièvre,Baies d’açaï'
|
||||
.split(',')
|
||||
.map((label) => ({ label, value: label }));
|
||||
|
||||
let combobox: Combobox;
|
||||
let currentState: State;
|
||||
|
||||
suite('single select without custom value', () => {
|
||||
suite('with default selection', () => {
|
||||
beforeEach(() => {
|
||||
combobox = new Combobox({
|
||||
options,
|
||||
selected: options.at(0) ?? null,
|
||||
render: (state) => {
|
||||
currentState = state;
|
||||
}
|
||||
});
|
||||
combobox.init();
|
||||
});
|
||||
|
||||
test('open select box and select option with click', () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection?.label).toBe('Fraises');
|
||||
|
||||
combobox.open();
|
||||
expect(currentState.open).toBeTruthy();
|
||||
|
||||
combobox.select('Mûres');
|
||||
expect(currentState.selection?.label).toBe('Mûres');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
});
|
||||
|
||||
test('open select box and select option with enter', () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection?.label).toBe('Fraises');
|
||||
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection?.label).toBe('Fraises');
|
||||
expect(currentState.focused?.label).toBe('Fraises');
|
||||
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.selection?.label).toBe('Fraises');
|
||||
expect(currentState.focused?.label).toBe('Myrtilles');
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.selection?.label).toBe('Myrtilles');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.selection?.label).toBe('Myrtilles');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
});
|
||||
|
||||
test('open select box and select option with tab', () => {
|
||||
combobox.keyboard('ArrowDown');
|
||||
combobox.keyboard('ArrowDown');
|
||||
|
||||
combobox.keyboard('Tab');
|
||||
expect(currentState.selection?.label).toBe('Myrtilles');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.hint).toEqual({
|
||||
type: 'selected',
|
||||
label: 'Myrtilles'
|
||||
});
|
||||
});
|
||||
|
||||
test('do not open select box on focus', () => {
|
||||
combobox.focus();
|
||||
expect(currentState.open).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
suite('empty', () => {
|
||||
beforeEach(() => {
|
||||
combobox = new Combobox({
|
||||
options,
|
||||
selected: null,
|
||||
render: (state) => {
|
||||
currentState = state;
|
||||
}
|
||||
});
|
||||
combobox.init();
|
||||
});
|
||||
|
||||
test('open select box on focus', () => {
|
||||
combobox.focus();
|
||||
expect(currentState.open).toBeTruthy();
|
||||
});
|
||||
|
||||
suite('open', () => {
|
||||
beforeEach(() => {
|
||||
combobox.open();
|
||||
});
|
||||
|
||||
test('if tab on empty input nothing is selected', () => {
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
combobox.keyboard('Tab');
|
||||
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
});
|
||||
|
||||
test('if enter on empty input nothing is selected', () => {
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
suite('closed', () => {
|
||||
test('if tab on empty input nothing is selected', () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Tab');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
});
|
||||
|
||||
test('if enter on empty input nothing is selected', () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('type exact match and press enter', () => {
|
||||
combobox.input('Baies');
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.options.length).toEqual(3);
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection?.label).toBe('Baies d’açaï');
|
||||
});
|
||||
|
||||
test('type exact match and press tab', () => {
|
||||
combobox.input('Baies');
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Tab');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection?.label).toBe('Baies d’açaï');
|
||||
expect(currentState.inputValue).toEqual('Baies d’açaï');
|
||||
});
|
||||
|
||||
test('type non matching input and press enter', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('');
|
||||
});
|
||||
|
||||
test('type non matching input and press tab', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Tab');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('');
|
||||
});
|
||||
|
||||
test('type non matching input and close', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.close();
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('');
|
||||
});
|
||||
|
||||
test('focus should circle', () => {
|
||||
combobox.input('Baie');
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.options.map(({ label }) => label)).toEqual([
|
||||
'Baies d’açaï',
|
||||
'Baies de genièvre',
|
||||
'Baies de sureau'
|
||||
]);
|
||||
expect(currentState.focused).toBeNull();
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.focused?.label).toBe('Baies d’açaï');
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.focused?.label).toBe('Baies de genièvre');
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.focused?.label).toBe('Baies de sureau');
|
||||
combobox.keyboard('ArrowDown');
|
||||
expect(currentState.focused?.label).toBe('Baies d’açaï');
|
||||
combobox.keyboard('ArrowUp');
|
||||
expect(currentState.focused?.label).toBe('Baies de sureau');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('single select with custom value', () => {
|
||||
beforeEach(() => {
|
||||
combobox = new Combobox({
|
||||
options,
|
||||
selected: null,
|
||||
allowsCustomValue: true,
|
||||
render: (state) => {
|
||||
currentState = state;
|
||||
}
|
||||
});
|
||||
combobox.init();
|
||||
});
|
||||
|
||||
test('type non matching input and press enter', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Enter');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('toto');
|
||||
});
|
||||
|
||||
test('type non matching input and press tab', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.keyboard('Tab');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('toto');
|
||||
});
|
||||
|
||||
test('type non matching input and close', () => {
|
||||
combobox.input('toto');
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
|
||||
combobox.close();
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.inputValue).toEqual('toto');
|
||||
});
|
||||
});
|
||||
});
|
265
app/javascript/shared/combobox.ts
Normal file
265
app/javascript/shared/combobox.ts
Normal file
|
@ -0,0 +1,265 @@
|
|||
import { matchSorter } from 'match-sorter';
|
||||
|
||||
export enum Action {
|
||||
Init = 'init',
|
||||
Open = 'open',
|
||||
Close = 'close',
|
||||
Navigate = 'navigate',
|
||||
Select = 'select',
|
||||
Clear = 'clear',
|
||||
Update = 'update'
|
||||
}
|
||||
export type Option = { value: string; label: string };
|
||||
export type Hint =
|
||||
| {
|
||||
type: 'results';
|
||||
label: string | null;
|
||||
count: number;
|
||||
}
|
||||
| { type: 'empty' }
|
||||
| { type: 'selected'; label: string };
|
||||
export type State = {
|
||||
action: Action;
|
||||
open: boolean;
|
||||
inputValue: string;
|
||||
focused: Option | null;
|
||||
selection: Option | null;
|
||||
options: Option[];
|
||||
allowsCustomValue: boolean;
|
||||
hint: Hint | null;
|
||||
};
|
||||
|
||||
export class Combobox {
|
||||
#allowsCustomValue = false;
|
||||
#open = false;
|
||||
#inputValue = '';
|
||||
#selectedOption: Option | null = null;
|
||||
#focusedOption: Option | null = null;
|
||||
#options: Option[] = [];
|
||||
#visibleOptions: Option[] = [];
|
||||
#render: (state: State) => void;
|
||||
|
||||
constructor({
|
||||
options,
|
||||
selected,
|
||||
value,
|
||||
allowsCustomValue,
|
||||
render
|
||||
}: {
|
||||
options: Option[];
|
||||
selected: Option | null;
|
||||
value?: string;
|
||||
allowsCustomValue?: boolean;
|
||||
render: (state: State) => void;
|
||||
}) {
|
||||
this.#allowsCustomValue = allowsCustomValue ?? false;
|
||||
this.#options = options;
|
||||
this.#selectedOption = selected;
|
||||
if (this.#selectedOption) {
|
||||
this.#inputValue = this.#selectedOption.label;
|
||||
} else if (value) {
|
||||
this.#inputValue = value;
|
||||
}
|
||||
this.#render = render;
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
this._render(Action.Init);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.#render = () => null;
|
||||
}
|
||||
|
||||
navigate(indexDiff: -1 | 1 = 1): void {
|
||||
const focusIndex = this._focusedOptionIndex;
|
||||
const lastIndex = this.#visibleOptions.length - 1;
|
||||
|
||||
let indexOfItem = indexDiff == 1 ? 0 : lastIndex;
|
||||
if (focusIndex == lastIndex && indexDiff == 1) {
|
||||
indexOfItem = 0;
|
||||
} else if (focusIndex == 0 && indexDiff == -1) {
|
||||
indexOfItem = lastIndex;
|
||||
} else if (focusIndex == -1) {
|
||||
indexOfItem = 0;
|
||||
} else {
|
||||
indexOfItem = focusIndex + indexDiff;
|
||||
}
|
||||
|
||||
this.#focusedOption = this.#visibleOptions.at(indexOfItem) ?? null;
|
||||
|
||||
this._render(Action.Navigate);
|
||||
}
|
||||
|
||||
select(value?: string): boolean {
|
||||
const maybeValue = this._nextSelectValue(value);
|
||||
if (!maybeValue) {
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
const option = this.#visibleOptions.find(
|
||||
(option) => option.value == maybeValue
|
||||
);
|
||||
if (!option) return false;
|
||||
|
||||
this.#selectedOption = option;
|
||||
this.#focusedOption = null;
|
||||
this.#inputValue = option.label;
|
||||
this.#open = false;
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
|
||||
this._render(Action.Select);
|
||||
return true;
|
||||
}
|
||||
|
||||
input(value: string) {
|
||||
if (this.#inputValue == value) return;
|
||||
this.#inputValue = value;
|
||||
this.#selectedOption = null;
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
if (this.#visibleOptions.length > 0) {
|
||||
if (!this.#open) {
|
||||
this.open();
|
||||
} else {
|
||||
this._render(Action.Update);
|
||||
}
|
||||
} else if (this.#allowsCustomValue) {
|
||||
this.#open = false;
|
||||
this.#focusedOption = null;
|
||||
this._render(Action.Close);
|
||||
}
|
||||
}
|
||||
|
||||
keyboard(key: string) {
|
||||
switch (key) {
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
return this.select();
|
||||
case 'Escape':
|
||||
this.close();
|
||||
return true;
|
||||
case 'ArrowDown':
|
||||
if (this.#open) {
|
||||
this.navigate(1);
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
return true;
|
||||
case 'ArrowUp':
|
||||
if (this.#open) {
|
||||
this.navigate(-1);
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.#inputValue && !this.#selectedOption) return;
|
||||
this.#inputValue = '';
|
||||
this.#selectedOption = this.#focusedOption = null;
|
||||
this.#visibleOptions = this.#options;
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
this._render(Action.Clear);
|
||||
}
|
||||
|
||||
open() {
|
||||
if (this.#open || this.#visibleOptions.length == 0) return;
|
||||
this.#open = true;
|
||||
this.#focusedOption = this.#selectedOption;
|
||||
this._render(Action.Open);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.#open) return;
|
||||
this.#open = false;
|
||||
this.#focusedOption = null;
|
||||
if (!this.#allowsCustomValue && !this.#selectedOption) {
|
||||
this.#inputValue = '';
|
||||
}
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
this._render(Action.Close);
|
||||
}
|
||||
|
||||
focus() {
|
||||
if (this.#open) return;
|
||||
if (this.#selectedOption) return;
|
||||
|
||||
this.open();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.#open ? this.close() : this.open();
|
||||
}
|
||||
|
||||
private _nextSelectValue(value?: string): string | false {
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
if (this.#focusedOption && this._focusedOptionIndex != -1) {
|
||||
return this.#focusedOption.value;
|
||||
}
|
||||
if (this.#allowsCustomValue) {
|
||||
return false;
|
||||
}
|
||||
if (this.#inputValue.length > 0 && !this.#selectedOption) {
|
||||
return this.#visibleOptions.at(0)?.value ?? false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _filterOptions(): Option[] {
|
||||
if (!this.#inputValue || this.#inputValue == this.#selectedOption?.value) {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
return matchSorter(this.#options, this.#inputValue, { keys: ['value'] });
|
||||
}
|
||||
|
||||
private get _focusedOptionIndex(): number {
|
||||
if (this.#focusedOption) {
|
||||
return this.#visibleOptions.indexOf(this.#focusedOption);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private _render(action: Action): void {
|
||||
this.#render(this._getState(action));
|
||||
}
|
||||
|
||||
private _getState(action: Action): State {
|
||||
const state = {
|
||||
action,
|
||||
open: this.#open,
|
||||
options: this.#visibleOptions,
|
||||
inputValue: this.#inputValue,
|
||||
focused: this.#focusedOption,
|
||||
selection: this.#selectedOption,
|
||||
allowsCustomValue: this.#allowsCustomValue,
|
||||
hint: null
|
||||
};
|
||||
|
||||
return { ...state, hint: this._getFeedback(state) };
|
||||
}
|
||||
|
||||
private _getFeedback(state: State): Hint | null {
|
||||
const count = state.options.length;
|
||||
if (state.action == Action.Open || state.action == Action.Update) {
|
||||
if (!state.selection) {
|
||||
const defaultOption = state.options.at(0);
|
||||
if (defaultOption) {
|
||||
return { type: 'results', label: defaultOption.label, count };
|
||||
} else if (count > 0) {
|
||||
return { type: 'results', label: null, count };
|
||||
}
|
||||
return { type: 'empty' };
|
||||
}
|
||||
} else if (state.action == Action.Select && state.selection) {
|
||||
return { type: 'selected', label: state.selection.label };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue