Merge pull request #9152 from tchak/feat-autocomplete-component
feat(combobox): implement a combobox input
This commit is contained in:
commit
4d08d193ba
13 changed files with 1226 additions and 1 deletions
|
@ -621,3 +621,18 @@ textarea::placeholder {
|
|||
background-color: $white;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fr-menu__list {
|
||||
padding: $default-spacer;
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
|
||||
.fr-menu__item {
|
||||
list-style-type: none;
|
||||
margin-bottom: $default-spacer;
|
||||
|
||||
&[aria-selected] {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
65
app/components/dsfr/combobox_component.rb
Normal file
65
app/components/dsfr/combobox_component.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
class Dsfr::ComboboxComponent < ApplicationComponent
|
||||
def initialize(form: nil, options:, selected: nil, allows_custom_value: false, **html_options)
|
||||
@form, @options, @selected, @allows_custom_value, @html_options = form, options, selected, allows_custom_value, html_options
|
||||
end
|
||||
|
||||
attr_reader :form, :options, :selected, :allows_custom_value
|
||||
|
||||
private
|
||||
|
||||
def name
|
||||
@html_options[:name]
|
||||
end
|
||||
|
||||
def form_id
|
||||
@html_options[:form_id]
|
||||
end
|
||||
|
||||
def html_input_options
|
||||
{
|
||||
type: 'text',
|
||||
autocomplete: 'off',
|
||||
spellcheck: 'false',
|
||||
id: input_id,
|
||||
class: input_class,
|
||||
value: input_value,
|
||||
'aria-expanded': 'false',
|
||||
'aria-describedby': @html_options[:describedby]
|
||||
}.compact
|
||||
end
|
||||
|
||||
def input_id
|
||||
@html_options[:id]
|
||||
end
|
||||
|
||||
def input_class
|
||||
"#{@html_options[:class].presence || ''} fr-select"
|
||||
end
|
||||
|
||||
def input_value
|
||||
selected.present? ? options_with_values.find { _1.last == selected }&.first : nil
|
||||
end
|
||||
|
||||
def list_id
|
||||
input_id.present? ? "#{input_id}-list" : nil
|
||||
end
|
||||
|
||||
def options_with_values
|
||||
options.map { _1.is_a?(Array) ? _1 : [_1, _1] }
|
||||
end
|
||||
|
||||
def options_json
|
||||
options_with_values.map { |(label, value)| { label:, value: } }.to_json
|
||||
end
|
||||
|
||||
def hints_json
|
||||
{
|
||||
empty: t(".sr.results", count: 0),
|
||||
one: t(".sr.results", count: 1),
|
||||
many: t(".sr.results", count: 2),
|
||||
oneWithLabel: t(".sr.results_with_label", count: 1),
|
||||
manyWithLabel: t(".sr.results_with_label", count: 2),
|
||||
selected: t(".sr.selected", count: 2)
|
||||
}.to_json
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
en:
|
||||
sr:
|
||||
results:
|
||||
zero: No result
|
||||
one: 1 result
|
||||
other: "{count} results"
|
||||
results_with_label:
|
||||
one: "1 result. {label} is the top result – press Enter to activate"
|
||||
other: "{count} results. {label} is the top result – press Enter to activate"
|
||||
selected: "{label} selected"
|
|
@ -0,0 +1,10 @@
|
|||
fr:
|
||||
sr:
|
||||
results:
|
||||
zero: Aucun résultat
|
||||
one: 1 résultat
|
||||
other: "{count} résultats"
|
||||
results_with_label:
|
||||
one: "1 résultat. {label} est le premier résultat – appuyez sur Entrée pour sélectionner"
|
||||
other: "{count} résultats. {label} est le premier résultat – appuyez sur Entrée pour sélectionner"
|
||||
selected: "{label} sélectionné"
|
|
@ -0,0 +1,13 @@
|
|||
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } }
|
||||
.fr-ds-combobox-input
|
||||
%input{ **html_input_options }
|
||||
- if form
|
||||
= form.hidden_field name, value: selected, form: form_id
|
||||
- else
|
||||
%input{ type: 'hidden', name:, value: selected, form: form_id }
|
||||
.fr-menu
|
||||
%ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, selected:, hints: hints_json } }
|
||||
.sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
|
||||
%template
|
||||
%li.fr-menu__item{ role: 'option' }
|
||||
%slot{ name: 'label' }
|
|
@ -18,6 +18,8 @@
|
|||
= @form.radio_button :value, Champs::DropDownListChamp::OTHER, checked: @champ.other?, id: "#{@champ.id}_radio_option_other"
|
||||
%label.fr-label{ for: "#{@champ.id}_radio_option_other" }
|
||||
Autre
|
||||
- elsif @champ.render_as_combobox?
|
||||
= render Dsfr::ComboboxComponent.new form: @form, name: :value, options: @champ.enabled_non_empty_options, selected: @champ.selected, id: @champ.input_id, class: select_class_names, describedby: @champ.describedby_id
|
||||
- else
|
||||
= @form.select :value,
|
||||
@champ.options.compact_blank,
|
||||
|
|
91
app/javascript/controllers/combobox_controller.ts
Normal file
91
app/javascript/controllers/combobox_controller.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import invariant from 'tiny-invariant';
|
||||
import { isInputElement, isElement } from '@coldwired/utils';
|
||||
|
||||
import { Hint } from '../shared/combobox';
|
||||
import { ComboboxUI } from '../shared/combobox-ui';
|
||||
import { ApplicationController } from './application_controller';
|
||||
|
||||
export class ComboboxController extends ApplicationController {
|
||||
#combobox?: ComboboxUI;
|
||||
|
||||
connect() {
|
||||
const { input, valueInput, list, item, hint } = this.getElements();
|
||||
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
this.#combobox = new ComboboxUI({
|
||||
input,
|
||||
valueInput,
|
||||
list,
|
||||
item,
|
||||
hint,
|
||||
allowsCustomValue: this.element.hasAttribute('data-allows-custom-value'),
|
||||
getHintText: (hint) => getHintText(hints, hint)
|
||||
});
|
||||
this.#combobox.init();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#combobox?.destroy();
|
||||
}
|
||||
|
||||
private getElements() {
|
||||
const input =
|
||||
this.element.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
const valueInput = this.element.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"]'
|
||||
);
|
||||
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
|
||||
const item = this.element.querySelector<HTMLTemplateElement>('template');
|
||||
const hint =
|
||||
this.element.querySelector<HTMLElement>('[aria-live]') ?? undefined;
|
||||
|
||||
invariant(
|
||||
isInputElement(input),
|
||||
'ComboboxController requires a input element'
|
||||
);
|
||||
invariant(
|
||||
isInputElement(valueInput),
|
||||
'ComboboxController requires a hidden input element'
|
||||
);
|
||||
invariant(
|
||||
isElement(list),
|
||||
'ComboboxController requires a [role=listbox] element'
|
||||
);
|
||||
invariant(
|
||||
isElement(item),
|
||||
'ComboboxController requires a template element'
|
||||
);
|
||||
|
||||
return { input, valueInput, list, item, hint };
|
||||
}
|
||||
}
|
||||
|
||||
function getHintText(hints: Record<string, string>, hint: Hint): string {
|
||||
const slot = hints[getSlotName(hint)];
|
||||
switch (hint.type) {
|
||||
case 'empty':
|
||||
return slot;
|
||||
case 'selected':
|
||||
return slot.replace('{label}', hint.label ?? '');
|
||||
default:
|
||||
return slot
|
||||
.replace('{count}', String(hint.count))
|
||||
.replace('{label}', hint.label ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
function getSlotName(hint: Hint): string {
|
||||
switch (hint.type) {
|
||||
case 'empty':
|
||||
return 'empty';
|
||||
case 'selected':
|
||||
return 'selected';
|
||||
default:
|
||||
if (hint.count == 1) {
|
||||
return hint.label ? 'oneWithLabel' : 'one';
|
||||
}
|
||||
return hint.label ? 'manyWithLabel' : 'many';
|
||||
}
|
||||
}
|
371
app/javascript/shared/combobox-ui.ts
Normal file
371
app/javascript/shared/combobox-ui.ts
Normal file
|
@ -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<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: () => 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<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.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.`;
|
||||
}
|
||||
}
|
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;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
class Champs::DropDownListChamp < Champ
|
||||
store_accessor :value_json, :other
|
||||
THRESHOLD_NB_OPTIONS_AS_RADIO = 5
|
||||
THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE = 20
|
||||
OTHER = '__other__'
|
||||
delegate :options_without_empty_value_when_mandatory, to: :type_de_champ
|
||||
|
||||
|
@ -10,6 +11,10 @@ class Champs::DropDownListChamp < Champ
|
|||
enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_RADIO
|
||||
end
|
||||
|
||||
def render_as_combobox?
|
||||
enabled_non_empty_options.size >= THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE
|
||||
end
|
||||
|
||||
def options?
|
||||
drop_down_list_options?
|
||||
end
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
= render partial: "layouts/favicons"
|
||||
|
||||
= vite_client_tag
|
||||
= vite_react_refresh_tag
|
||||
= vite_javascript_tag 'application'
|
||||
|
||||
= preload_link_tag(asset_url("Marianne-Regular.woff2"))
|
||||
|
@ -22,7 +23,7 @@
|
|||
= vite_stylesheet_tag 'main', media: 'all'
|
||||
= stylesheet_link_tag 'application', media: 'all'
|
||||
|
||||
%body{ class: browser.platform.ios? ? 'ios' : nil }
|
||||
%body{ class: browser.platform.ios? ? 'ios' : nil, data: { controller: 'turbo' } }
|
||||
.page-wrapper
|
||||
%main.m-6
|
||||
= content_for?(:content) ? yield(:content) : yield
|
||||
|
|
112
spec/components/previews/dsfr/combobox_component_preview.rb
Normal file
112
spec/components/previews/dsfr/combobox_component_preview.rb
Normal file
|
@ -0,0 +1,112 @@
|
|||
class Dsfr::ComboboxComponentPreview < ViewComponent::Preview
|
||||
OPTIONS = [
|
||||
'Cheddar',
|
||||
'Brie',
|
||||
'Mozzarella',
|
||||
'Gouda',
|
||||
'Swiss',
|
||||
'Parmesan',
|
||||
'Feta',
|
||||
'Blue cheese',
|
||||
'Camembert',
|
||||
'Monterey Jack',
|
||||
'Roquefort',
|
||||
'Provolone',
|
||||
'Colby',
|
||||
'Havarti',
|
||||
'Ricotta',
|
||||
'Pepper Jack',
|
||||
'Muenster',
|
||||
'Fontina',
|
||||
'Limburger',
|
||||
'Asiago',
|
||||
'Cottage cheese',
|
||||
'Emmental',
|
||||
'Mascarpone',
|
||||
'Taleggio',
|
||||
'Gruyere',
|
||||
'Edam',
|
||||
'Pecorino Romano',
|
||||
'Manchego',
|
||||
'Halloumi',
|
||||
'Jarlsberg',
|
||||
'Munster',
|
||||
'Stilton',
|
||||
'Gorgonzola',
|
||||
'Queso blanco',
|
||||
'Queso fresco',
|
||||
'Queso de bola',
|
||||
'Queso de cabra',
|
||||
'Queso panela',
|
||||
'Queso Oaxaca',
|
||||
'Queso Chihuahua',
|
||||
'Queso manchego',
|
||||
'Queso de bola',
|
||||
'Queso de bola de cabra',
|
||||
'Queso de bola de vaca',
|
||||
'Queso de bola de oveja',
|
||||
'Queso de bola de mezcla',
|
||||
'Queso de bola de leche cruda',
|
||||
'Queso de bola de leche pasteurizada',
|
||||
'Queso de bola de leche de cabra',
|
||||
'Queso de bola de leche de vaca',
|
||||
'Queso de bola de leche de oveja',
|
||||
'Queso de bola de leche de mezcla',
|
||||
'Burrata',
|
||||
'Scamorza',
|
||||
'Caciocavallo',
|
||||
'Provolone piccante',
|
||||
'Pecorino sardo',
|
||||
'Pecorino toscano',
|
||||
'Pecorino siciliano',
|
||||
'Pecorino calabrese',
|
||||
'Pecorino moliterno',
|
||||
'Pecorino di fossa',
|
||||
'Pecorino di filiano',
|
||||
'Pecorino di pienza',
|
||||
'Pecorino di grotta',
|
||||
'Pecorino di capra',
|
||||
'Pecorino di mucca',
|
||||
'Pecorino di pecora',
|
||||
'Pecorino di bufala',
|
||||
'Cacio di bosco',
|
||||
'Cacio di roma',
|
||||
'Cacio di fossa',
|
||||
'Cacio di tricarico',
|
||||
'Cacio di cavallo',
|
||||
'Cacio di capra',
|
||||
'Cacio di mucca',
|
||||
'Cacio di pecora',
|
||||
'Cacio di bufala',
|
||||
'Taleggio di capra',
|
||||
'Taleggio di mucca',
|
||||
'Taleggio di pecora',
|
||||
'Taleggio di bufala',
|
||||
'Bel Paese',
|
||||
'Crescenza',
|
||||
'Stracchino',
|
||||
'Robiola',
|
||||
'Toma',
|
||||
'Bra',
|
||||
'Castelmagno',
|
||||
'Raschera',
|
||||
'Montasio',
|
||||
'Piave',
|
||||
'Bitto',
|
||||
'Quartirolo Lombardo',
|
||||
'Formaggella del Luinese',
|
||||
'Formaggella della Val Vigezzo',
|
||||
'Formaggella della Valle Grana',
|
||||
'Formaggella della Val Bognanco',
|
||||
'Formaggella della Val d’Intelvi',
|
||||
'Formaggella della Val Gerola'
|
||||
]
|
||||
|
||||
def simple_select_with_options
|
||||
render Dsfr::ComboboxComponent.new(name: :value, options: OPTIONS, selected: OPTIONS.sample, id: 'simple-select', class: 'width-33')
|
||||
end
|
||||
|
||||
def simple_select_with_options_and_allows_custom_value
|
||||
render Dsfr::ComboboxComponent.new(name: :value, options: OPTIONS, selected: OPTIONS.sample, id: 'simple-select', class: 'width-33', allows_custom_value: true)
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue