Merge pull request #9495 from tchak/feat-autocomplete-from-api
wip(autocomplete): autocomplete from url
This commit is contained in:
commit
c995a06434
11 changed files with 241 additions and 79 deletions
20
Gemfile.lock
20
Gemfile.lock
|
@ -86,7 +86,7 @@ GEM
|
|||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
addressable (2.8.4)
|
||||
addressable (2.8.5)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
administrate (0.18.0)
|
||||
actionpack (>= 5.0)
|
||||
|
@ -103,13 +103,10 @@ GEM
|
|||
anchored (1.1.0)
|
||||
ast (2.4.2)
|
||||
attr_required (1.0.1)
|
||||
axe-core-api (4.2.1)
|
||||
capybara
|
||||
axe-core-api (4.8.0)
|
||||
dumb_delegator
|
||||
selenium-webdriver
|
||||
virtus
|
||||
watir
|
||||
axe-core-rspec (4.2.1)
|
||||
axe-core-rspec (4.8.0)
|
||||
axe-core-api
|
||||
dumb_delegator
|
||||
virtus
|
||||
|
@ -487,7 +484,7 @@ GEM
|
|||
pry (>= 0.13, < 0.15)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (5.0.1)
|
||||
public_suffix (5.0.3)
|
||||
puma (6.3.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
|
@ -576,7 +573,7 @@ GEM
|
|||
http-cookie (>= 1.0.2, < 2.0)
|
||||
mime-types (>= 1.16, < 4.0)
|
||||
netrc (~> 0.8)
|
||||
rexml (3.2.5)
|
||||
rexml (3.2.6)
|
||||
rodf (1.1.1)
|
||||
builder (>= 3.0)
|
||||
dry-inflector (~> 0.1)
|
||||
|
@ -666,7 +663,7 @@ GEM
|
|||
selectize-rails (0.12.6)
|
||||
selenium-devtools (0.114.0)
|
||||
selenium-webdriver (~> 4.2)
|
||||
selenium-webdriver (4.10.0)
|
||||
selenium-webdriver (4.13.1)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
|
@ -763,9 +760,6 @@ GEM
|
|||
zeitwerk (~> 2.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
watir (6.19.1)
|
||||
regexp_parser (>= 1.2, < 3)
|
||||
selenium-webdriver (>= 3.142.7)
|
||||
web-console (4.1.0)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
|
@ -778,7 +772,7 @@ GEM
|
|||
addressable (>= 2.3.6)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket (1.2.9)
|
||||
websocket (1.2.10)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
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
|
||||
def initialize(form: nil, options: nil, url: nil, selected: nil, allows_custom_value: false, **html_options)
|
||||
@form, @options, @url, @selected, @allows_custom_value, @html_options = form, options, url, selected, allows_custom_value, html_options
|
||||
end
|
||||
|
||||
attr_reader :form, :options, :selected, :allows_custom_value
|
||||
attr_reader :form, :options, :url, :selected, :allows_custom_value
|
||||
|
||||
private
|
||||
|
||||
|
@ -22,7 +22,7 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
|||
spellcheck: 'false',
|
||||
id: input_id,
|
||||
class: input_class,
|
||||
value: input_value,
|
||||
role: 'combobox',
|
||||
'aria-expanded': 'false',
|
||||
'aria-describedby': @html_options[:describedby]
|
||||
}.compact
|
||||
|
@ -36,8 +36,22 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
|||
"#{@html_options[:class].presence || ''} fr-select"
|
||||
end
|
||||
|
||||
def input_value
|
||||
selected.present? ? options_with_values.find { _1.last == selected }&.first : nil
|
||||
def selected_option_label_input_value
|
||||
if selected.is_a?(Array) && selected.size == 2
|
||||
selected.first
|
||||
elsif options.present?
|
||||
selected.present? ? options_with_values.find { _1.last == selected }&.first : nil
|
||||
else
|
||||
selected
|
||||
end
|
||||
end
|
||||
|
||||
def selected_option_value_input_value
|
||||
if selected.is_a?(Array) && selected.size == 2
|
||||
selected.last
|
||||
else
|
||||
selected
|
||||
end
|
||||
end
|
||||
|
||||
def list_id
|
||||
|
@ -45,10 +59,12 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
|||
end
|
||||
|
||||
def options_with_values
|
||||
return [] if url.present?
|
||||
options.map { _1.is_a?(Array) ? _1 : [_1, _1] }
|
||||
end
|
||||
|
||||
def options_json
|
||||
return nil if url.present?
|
||||
options_with_values.map { |(label, value)| { label:, value: } }.to_json
|
||||
end
|
||||
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } }
|
||||
.fr-ds-combobox-input
|
||||
%input{ **html_input_options }
|
||||
%input{ value: selected_option_label_input_value, **html_input_options }
|
||||
- if form
|
||||
= form.hidden_field name, value: selected, form: form_id
|
||||
= form.hidden_field name, value: selected_option_value_input_value, form: form_id
|
||||
- else
|
||||
%input{ type: 'hidden', name:, value: selected, form: form_id }
|
||||
%input{ type: 'hidden', name:, value: selected_option_value_input_value, 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 } }
|
||||
%ul.fr-menu__list.hidden{ role: 'listbox', hidden: true, id: list_id, data: { turbo_force: :browser, options: options_json, url:, hints: hints_json }.compact }
|
||||
.sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
|
||||
%template
|
||||
%li.fr-menu__item{ role: 'option' }
|
||||
%slot{ name: 'label' }
|
||||
= content
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
|
||||
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
|
||||
end
|
||||
|
|
|
@ -1,11 +1,2 @@
|
|||
- render_parent
|
||||
|
||||
= @form.hidden_field :value
|
||||
= @form.hidden_field :external_id
|
||||
|
||||
= react_component("ComboAdresseSearch",
|
||||
required: @champ.required?,
|
||||
id: @champ.input_id,
|
||||
describedby: @champ.describedby_id,
|
||||
**react_combo_props,
|
||||
)
|
||||
= render Dsfr::ComboboxComponent.new form: @form, name: :value, url: data_sources_data_source_adresse_path, selected: @champ.value, id: @champ.input_id, class: 'fr-select', describedby: @champ.describedby_id do
|
||||
= @form.hidden_field :external_id, data: { value_slot: 'value' }
|
||||
|
|
11
app/controllers/data_sources/adresse_controller.rb
Normal file
11
app/controllers/data_sources/adresse_controller.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
class DataSources::AdresseController < ApplicationController
|
||||
def search
|
||||
if params[:q].present? && params[:q].length > 3
|
||||
response = Typhoeus.get("#{API_ADRESSE_URL}/search", params: { q: params[:q], limit: 10 })
|
||||
result = JSON.parse(response.body, symbolize_names: true)
|
||||
render json: result[:features].map { { label: _1[:properties][:label], value: _1[:properties][:label] } }
|
||||
else
|
||||
render json: []
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,14 +9,16 @@ export class ComboboxController extends ApplicationController {
|
|||
#combobox?: ComboboxUI;
|
||||
|
||||
connect() {
|
||||
const { input, valueInput, list, item, hint } = this.getElements();
|
||||
const { input, selectedValueInput, valueSlots, list, item, hint } =
|
||||
this.getElements();
|
||||
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
this.#combobox = new ComboboxUI({
|
||||
input,
|
||||
valueInput,
|
||||
selectedValueInput,
|
||||
valueSlots,
|
||||
list,
|
||||
item,
|
||||
hint,
|
||||
|
@ -33,9 +35,12 @@ export class ComboboxController extends ApplicationController {
|
|||
private getElements() {
|
||||
const input =
|
||||
this.element.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
const valueInput = this.element.querySelector<HTMLInputElement>(
|
||||
const selectedValueInput = this.element.querySelector<HTMLInputElement>(
|
||||
'input[type="hidden"]'
|
||||
);
|
||||
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="hidden"][data-value-slot]'
|
||||
);
|
||||
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
|
||||
const item = this.element.querySelector<HTMLTemplateElement>('template');
|
||||
const hint =
|
||||
|
@ -46,7 +51,7 @@ export class ComboboxController extends ApplicationController {
|
|||
'ComboboxController requires a input element'
|
||||
);
|
||||
invariant(
|
||||
isInputElement(valueInput),
|
||||
isInputElement(selectedValueInput),
|
||||
'ComboboxController requires a hidden input element'
|
||||
);
|
||||
invariant(
|
||||
|
@ -58,7 +63,7 @@ export class ComboboxController extends ApplicationController {
|
|||
'ComboboxController requires a template element'
|
||||
);
|
||||
|
||||
return { input, valueInput, list, item, hint };
|
||||
return { input, selectedValueInput, valueSlots, list, item, hint };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,15 +3,23 @@ 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';
|
||||
import {
|
||||
Combobox,
|
||||
Action,
|
||||
type State,
|
||||
type Option,
|
||||
type Hint,
|
||||
type Fetcher
|
||||
} from './combobox';
|
||||
|
||||
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
|
||||
|
||||
export type ComboboxUIOptions = {
|
||||
input: HTMLInputElement;
|
||||
valueInput: HTMLInputElement;
|
||||
selectedValueInput: HTMLInputElement;
|
||||
list: HTMLUListElement;
|
||||
item: HTMLTemplateElement;
|
||||
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
|
||||
allowsCustomValue?: boolean;
|
||||
hint?: HTMLElement;
|
||||
getHintText?: (hint: Hint) => string;
|
||||
|
@ -25,7 +33,8 @@ export class ComboboxUI implements EventListenerObject {
|
|||
#isComposing = false;
|
||||
|
||||
#input: HTMLInputElement;
|
||||
#valueInput: HTMLInputElement;
|
||||
#selectedValueInput: HTMLInputElement;
|
||||
#valueSlots: HTMLInputElement[];
|
||||
#list: HTMLUListElement;
|
||||
#item: HTMLTemplateElement;
|
||||
#hint?: HTMLElement;
|
||||
|
@ -35,7 +44,8 @@ export class ComboboxUI implements EventListenerObject {
|
|||
|
||||
constructor({
|
||||
input,
|
||||
valueInput,
|
||||
selectedValueInput,
|
||||
valueSlots,
|
||||
list,
|
||||
item,
|
||||
hint,
|
||||
|
@ -43,7 +53,8 @@ export class ComboboxUI implements EventListenerObject {
|
|||
allowsCustomValue
|
||||
}: ComboboxUIOptions) {
|
||||
this.#input = input;
|
||||
this.#valueInput = valueInput;
|
||||
this.#selectedValueInput = selectedValueInput;
|
||||
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
|
||||
this.#list = list;
|
||||
this.#item = item;
|
||||
this.#hint = hint;
|
||||
|
@ -52,20 +63,39 @@ export class ComboboxUI implements EventListenerObject {
|
|||
}
|
||||
|
||||
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;
|
||||
if (this.#list.dataset.url) {
|
||||
const fetcher = createFetcher(this.#list.dataset.url);
|
||||
|
||||
this.#list.removeAttribute('data-url');
|
||||
|
||||
const selected: Option | null = this.#input.value
|
||||
? { label: this.#input.value, value: this.#selectedValueInput.value }
|
||||
: null;
|
||||
this.#combobox = new Combobox({
|
||||
options: fetcher,
|
||||
selected,
|
||||
allowsCustomValue: this.#allowsCustomValue,
|
||||
render: (state) => this.render(state)
|
||||
});
|
||||
} else {
|
||||
const selectedValue = this.#selectedValueInput.value;
|
||||
const options = JSON.parse(
|
||||
this.#list.dataset.options ?? '[]'
|
||||
) as Option[];
|
||||
const selected =
|
||||
options.find(({ value }) => value == selectedValue) ?? null;
|
||||
|
||||
this.#list.removeAttribute('data-options');
|
||||
this.#list.removeAttribute('data-selected');
|
||||
|
||||
this.#combobox = new Combobox({
|
||||
options,
|
||||
selected,
|
||||
allowsCustomValue: this.#allowsCustomValue,
|
||||
render: (state) => this.render(state)
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -192,18 +222,20 @@ export class ComboboxUI implements EventListenerObject {
|
|||
this.#input.value = state.inputValue;
|
||||
}
|
||||
this.dispatchChange(() => {
|
||||
if (this.#valueInput.value != state.inputValue) {
|
||||
if (this.#selectedValueInput.value != state.inputValue) {
|
||||
if (state.allowsCustomValue || !state.inputValue) {
|
||||
this.#valueInput.value = state.inputValue;
|
||||
this.#selectedValueInput.value = state.inputValue;
|
||||
}
|
||||
}
|
||||
return state.selection?.data;
|
||||
});
|
||||
}
|
||||
|
||||
private renderSelect(state: State): void {
|
||||
this.dispatchChange(() => {
|
||||
this.#valueInput.value = state.selection?.value ?? '';
|
||||
this.#selectedValueInput.value = state.selection?.value ?? '';
|
||||
this.#input.value = state.selection?.label ?? '';
|
||||
return state.selection?.data;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -252,12 +284,28 @@ export class ComboboxUI implements EventListenerObject {
|
|||
}
|
||||
}
|
||||
|
||||
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 dispatchChange(cb: () => Option['data']): void {
|
||||
const value = this.#selectedValueInput.value;
|
||||
const data = cb();
|
||||
if (value != this.#selectedValueInput.value) {
|
||||
for (const input of this.#valueSlots) {
|
||||
switch (input.dataset.valueSlot) {
|
||||
case 'value':
|
||||
input.value = this.#selectedValueInput.value;
|
||||
break;
|
||||
case 'label':
|
||||
input.value = this.#input.value;
|
||||
break;
|
||||
case 'data':
|
||||
input.value = data ? JSON.stringify(data) : '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.debug('combobox change', this.#selectedValueInput.value);
|
||||
dispatch('change', {
|
||||
target: this.#selectedValueInput,
|
||||
detail: data ? { data } : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -353,7 +401,12 @@ function inViewport(container: HTMLElement, element: HTMLElement): boolean {
|
|||
}
|
||||
|
||||
function optionId(value: string) {
|
||||
return `option-${value.replace(/\s/g, '-')}`;
|
||||
return `option-${value
|
||||
.toLowerCase()
|
||||
// Replace spaces and special characters with underscores
|
||||
.replace(/[^a-z0-9]/g, '_')
|
||||
// Remove non-alphanumeric characters at start and end
|
||||
.replace(/^[^a-z]+|[^\w]$/g, '')}`;
|
||||
}
|
||||
|
||||
function defaultGetHintText(hint: Hint): string {
|
||||
|
@ -369,3 +422,37 @@ function defaultGetHintText(hint: Hint): string {
|
|||
return `${hint.label} selected.`;
|
||||
}
|
||||
}
|
||||
|
||||
function createFetcher(source: string, param = 'q'): Fetcher {
|
||||
const url = new URL(source, location.href);
|
||||
|
||||
const fetcher: Fetcher = (term: string, options) => {
|
||||
url.searchParams.set(param, term);
|
||||
return fetch(url.toString(), {
|
||||
headers: { accept: 'application/json' },
|
||||
signal: options?.signal
|
||||
}).then<Option[]>((response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
|
||||
return async (term: string, options) => {
|
||||
await wait(500, options?.signal);
|
||||
return fetcher(term, options);
|
||||
};
|
||||
}
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const abort = () => reject(new DOMException('Aborted', 'AbortError'));
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
signal?.addEventListener('abort', abort);
|
||||
setTimeout(resolve, ms);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { suite, test, beforeEach, expect } from 'vitest';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
|
||||
import { Combobox, Option, State } from './combobox';
|
||||
|
||||
|
@ -26,6 +27,7 @@ suite('Combobox', () => {
|
|||
|
||||
test('open select box and select option with click', () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.loading).toBe(null);
|
||||
expect(currentState.selection?.label).toBe('Fraises');
|
||||
|
||||
combobox.open();
|
||||
|
@ -262,4 +264,32 @@ suite('Combobox', () => {
|
|||
expect(currentState.inputValue).toEqual('toto');
|
||||
});
|
||||
});
|
||||
|
||||
suite('single select with fetcher', () => {
|
||||
beforeEach(() => {
|
||||
combobox = new Combobox({
|
||||
options: (term: string) =>
|
||||
Promise.resolve(matchSorter(options, term, { keys: ['value'] })),
|
||||
selected: null,
|
||||
render: (state) => {
|
||||
currentState = state;
|
||||
}
|
||||
});
|
||||
combobox.init();
|
||||
});
|
||||
|
||||
test('type and get options from fetcher', async () => {
|
||||
expect(currentState.open).toBeFalsy();
|
||||
expect(currentState.loading).toBe(false);
|
||||
|
||||
const result = combobox.input('Baies');
|
||||
|
||||
expect(currentState.loading).toBe(true);
|
||||
await result;
|
||||
expect(currentState.loading).toBe(false);
|
||||
expect(currentState.open).toBeTruthy();
|
||||
expect(currentState.selection).toBeNull();
|
||||
expect(currentState.options.length).toEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@ export enum Action {
|
|||
Clear = 'clear',
|
||||
Update = 'update'
|
||||
}
|
||||
export type Option = { value: string; label: string };
|
||||
export type Option = { value: string; label: string; data?: unknown };
|
||||
export type Hint =
|
||||
| {
|
||||
type: 'results';
|
||||
|
@ -27,8 +27,14 @@ export type State = {
|
|||
options: Option[];
|
||||
allowsCustomValue: boolean;
|
||||
hint: Hint | null;
|
||||
loading: boolean | null;
|
||||
};
|
||||
|
||||
export type Fetcher = (
|
||||
term: string,
|
||||
options?: { signal: AbortSignal }
|
||||
) => Promise<Option[]>;
|
||||
|
||||
export class Combobox {
|
||||
#allowsCustomValue = false;
|
||||
#open = false;
|
||||
|
@ -38,27 +44,26 @@ export class Combobox {
|
|||
#options: Option[] = [];
|
||||
#visibleOptions: Option[] = [];
|
||||
#render: (state: State) => void;
|
||||
#fetcher: Fetcher | null;
|
||||
#abortController?: AbortController | null;
|
||||
|
||||
constructor({
|
||||
options,
|
||||
selected,
|
||||
value,
|
||||
allowsCustomValue,
|
||||
render
|
||||
}: {
|
||||
options: Option[];
|
||||
options: Option[] | Fetcher;
|
||||
selected: Option | null;
|
||||
value?: string;
|
||||
allowsCustomValue?: boolean;
|
||||
render: (state: State) => void;
|
||||
}) {
|
||||
this.#allowsCustomValue = allowsCustomValue ?? false;
|
||||
this.#options = options;
|
||||
this.#options = Array.isArray(options) ? options : [];
|
||||
this.#fetcher = Array.isArray(options) ? null : options;
|
||||
this.#selectedOption = selected;
|
||||
if (this.#selectedOption) {
|
||||
this.#inputValue = this.#selectedOption.label;
|
||||
} else if (value) {
|
||||
this.#inputValue = value;
|
||||
}
|
||||
this.#render = render;
|
||||
}
|
||||
|
@ -114,11 +119,28 @@ export class Combobox {
|
|||
return true;
|
||||
}
|
||||
|
||||
input(value: string) {
|
||||
async input(value: string) {
|
||||
if (this.#inputValue == value) return;
|
||||
|
||||
this.#inputValue = value;
|
||||
this.#selectedOption = null;
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
|
||||
if (this.#fetcher) {
|
||||
this.#abortController?.abort();
|
||||
this.#abortController = new AbortController();
|
||||
this._render(Action.Update);
|
||||
this.#options = await this.#fetcher(value, {
|
||||
signal: this.#abortController.signal
|
||||
}).catch(() => []);
|
||||
this.#abortController = null;
|
||||
this._render(Action.Update);
|
||||
|
||||
this.#selectedOption = null;
|
||||
this.#visibleOptions = this.#options;
|
||||
} else {
|
||||
this.#selectedOption = null;
|
||||
this.#visibleOptions = this._filterOptions();
|
||||
}
|
||||
|
||||
if (this.#visibleOptions.length > 0) {
|
||||
if (!this.#open) {
|
||||
this.open();
|
||||
|
@ -239,7 +261,8 @@ export class Combobox {
|
|||
focused: this.#focusedOption,
|
||||
selection: this.#selectedOption,
|
||||
allowsCustomValue: this.#allowsCustomValue,
|
||||
hint: null
|
||||
hint: null,
|
||||
loading: this.#abortController ? true : this.#fetcher ? false : null
|
||||
};
|
||||
|
||||
return { ...state, hint: this._getFeedback(state) };
|
||||
|
|
|
@ -221,6 +221,10 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
|
||||
namespace :data_sources do
|
||||
get :adresse, to: 'adresse#search', as: :data_source_adresse
|
||||
end
|
||||
|
||||
#
|
||||
# Deprecated UI
|
||||
#
|
||||
|
|
Loading…
Reference in a new issue