Merge pull request #9152 from tchak/feat-autocomplete-component

feat(combobox): implement a combobox input
This commit is contained in:
Paul Chavard 2023-09-15 14:24:40 +00:00 committed by GitHub
commit 4d08d193ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 1226 additions and 1 deletions

View file

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

View 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

View file

@ -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"

View file

@ -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é"

View file

@ -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' }

View file

@ -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,

View 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';
}
}

View 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.`;
}
}

View 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 daç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 daç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 daçaï');
expect(currentState.inputValue).toEqual('Baies daç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 daçaï',
'Baies de genièvre',
'Baies de sureau'
]);
expect(currentState.focused).toBeNull();
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daç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 daç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');
});
});
});

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

View file

@ -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

View file

@ -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

View 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 dIntelvi',
'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