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;
|
background-color: $white;
|
||||||
z-index: 2;
|
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"
|
= @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" }
|
%label.fr-label{ for: "#{@champ.id}_radio_option_other" }
|
||||||
Autre
|
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
|
- else
|
||||||
= @form.select :value,
|
= @form.select :value,
|
||||||
@champ.options.compact_blank,
|
@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
|
class Champs::DropDownListChamp < Champ
|
||||||
store_accessor :value_json, :other
|
store_accessor :value_json, :other
|
||||||
THRESHOLD_NB_OPTIONS_AS_RADIO = 5
|
THRESHOLD_NB_OPTIONS_AS_RADIO = 5
|
||||||
|
THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE = 20
|
||||||
OTHER = '__other__'
|
OTHER = '__other__'
|
||||||
delegate :options_without_empty_value_when_mandatory, to: :type_de_champ
|
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
|
enabled_non_empty_options.size <= THRESHOLD_NB_OPTIONS_AS_RADIO
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_as_combobox?
|
||||||
|
enabled_non_empty_options.size >= THRESHOLD_NB_OPTIONS_AS_AUTOCOMPLETE
|
||||||
|
end
|
||||||
|
|
||||||
def options?
|
def options?
|
||||||
drop_down_list_options?
|
drop_down_list_options?
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
= render partial: "layouts/favicons"
|
= render partial: "layouts/favicons"
|
||||||
|
|
||||||
= vite_client_tag
|
= vite_client_tag
|
||||||
|
= vite_react_refresh_tag
|
||||||
= vite_javascript_tag 'application'
|
= vite_javascript_tag 'application'
|
||||||
|
|
||||||
= preload_link_tag(asset_url("Marianne-Regular.woff2"))
|
= preload_link_tag(asset_url("Marianne-Regular.woff2"))
|
||||||
|
@ -22,7 +23,7 @@
|
||||||
= vite_stylesheet_tag 'main', media: 'all'
|
= vite_stylesheet_tag 'main', media: 'all'
|
||||||
= stylesheet_link_tag 'application', 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
|
.page-wrapper
|
||||||
%main.m-6
|
%main.m-6
|
||||||
= content_for?(:content) ? yield(:content) : yield
|
= 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…
Add table
Reference in a new issue