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)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
addressable (2.8.4)
|
addressable (2.8.5)
|
||||||
public_suffix (>= 2.0.2, < 6.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
administrate (0.18.0)
|
administrate (0.18.0)
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
|
@ -103,13 +103,10 @@ GEM
|
||||||
anchored (1.1.0)
|
anchored (1.1.0)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
attr_required (1.0.1)
|
attr_required (1.0.1)
|
||||||
axe-core-api (4.2.1)
|
axe-core-api (4.8.0)
|
||||||
capybara
|
|
||||||
dumb_delegator
|
dumb_delegator
|
||||||
selenium-webdriver
|
|
||||||
virtus
|
virtus
|
||||||
watir
|
axe-core-rspec (4.8.0)
|
||||||
axe-core-rspec (4.2.1)
|
|
||||||
axe-core-api
|
axe-core-api
|
||||||
dumb_delegator
|
dumb_delegator
|
||||||
virtus
|
virtus
|
||||||
|
@ -487,7 +484,7 @@ GEM
|
||||||
pry (>= 0.13, < 0.15)
|
pry (>= 0.13, < 0.15)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (5.0.1)
|
public_suffix (5.0.3)
|
||||||
puma (6.3.1)
|
puma (6.3.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.2.0)
|
pundit (2.2.0)
|
||||||
|
@ -576,7 +573,7 @@ GEM
|
||||||
http-cookie (>= 1.0.2, < 2.0)
|
http-cookie (>= 1.0.2, < 2.0)
|
||||||
mime-types (>= 1.16, < 4.0)
|
mime-types (>= 1.16, < 4.0)
|
||||||
netrc (~> 0.8)
|
netrc (~> 0.8)
|
||||||
rexml (3.2.5)
|
rexml (3.2.6)
|
||||||
rodf (1.1.1)
|
rodf (1.1.1)
|
||||||
builder (>= 3.0)
|
builder (>= 3.0)
|
||||||
dry-inflector (~> 0.1)
|
dry-inflector (~> 0.1)
|
||||||
|
@ -666,7 +663,7 @@ GEM
|
||||||
selectize-rails (0.12.6)
|
selectize-rails (0.12.6)
|
||||||
selenium-devtools (0.114.0)
|
selenium-devtools (0.114.0)
|
||||||
selenium-webdriver (~> 4.2)
|
selenium-webdriver (~> 4.2)
|
||||||
selenium-webdriver (4.10.0)
|
selenium-webdriver (4.13.1)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 3.0)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
|
@ -763,9 +760,6 @@ GEM
|
||||||
zeitwerk (~> 2.2)
|
zeitwerk (~> 2.2)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
watir (6.19.1)
|
|
||||||
regexp_parser (>= 1.2, < 3)
|
|
||||||
selenium-webdriver (>= 3.142.7)
|
|
||||||
web-console (4.1.0)
|
web-console (4.1.0)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
|
@ -778,7 +772,7 @@ GEM
|
||||||
addressable (>= 2.3.6)
|
addressable (>= 2.3.6)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket (1.2.9)
|
websocket (1.2.10)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
class Dsfr::ComboboxComponent < ApplicationComponent
|
class Dsfr::ComboboxComponent < ApplicationComponent
|
||||||
def initialize(form: nil, options:, selected: nil, allows_custom_value: false, **html_options)
|
def initialize(form: nil, options: nil, url: nil, selected: nil, allows_custom_value: false, **html_options)
|
||||||
@form, @options, @selected, @allows_custom_value, @html_options = form, options, selected, allows_custom_value, html_options
|
@form, @options, @url, @selected, @allows_custom_value, @html_options = form, options, url, selected, allows_custom_value, html_options
|
||||||
end
|
end
|
||||||
|
|
||||||
attr_reader :form, :options, :selected, :allows_custom_value
|
attr_reader :form, :options, :url, :selected, :allows_custom_value
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
||||||
spellcheck: 'false',
|
spellcheck: 'false',
|
||||||
id: input_id,
|
id: input_id,
|
||||||
class: input_class,
|
class: input_class,
|
||||||
value: input_value,
|
role: 'combobox',
|
||||||
'aria-expanded': 'false',
|
'aria-expanded': 'false',
|
||||||
'aria-describedby': @html_options[:describedby]
|
'aria-describedby': @html_options[:describedby]
|
||||||
}.compact
|
}.compact
|
||||||
|
@ -36,8 +36,22 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
||||||
"#{@html_options[:class].presence || ''} fr-select"
|
"#{@html_options[:class].presence || ''} fr-select"
|
||||||
end
|
end
|
||||||
|
|
||||||
def input_value
|
def selected_option_label_input_value
|
||||||
selected.present? ? options_with_values.find { _1.last == selected }&.first : nil
|
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
|
end
|
||||||
|
|
||||||
def list_id
|
def list_id
|
||||||
|
@ -45,10 +59,12 @@ class Dsfr::ComboboxComponent < ApplicationComponent
|
||||||
end
|
end
|
||||||
|
|
||||||
def options_with_values
|
def options_with_values
|
||||||
|
return [] if url.present?
|
||||||
options.map { _1.is_a?(Array) ? _1 : [_1, _1] }
|
options.map { _1.is_a?(Array) ? _1 : [_1, _1] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def options_json
|
def options_json
|
||||||
|
return nil if url.present?
|
||||||
options_with_values.map { |(label, value)| { label:, value: } }.to_json
|
options_with_values.map { |(label, value)| { label:, value: } }.to_json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } }
|
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value } }
|
||||||
.fr-ds-combobox-input
|
.fr-ds-combobox-input
|
||||||
%input{ **html_input_options }
|
%input{ value: selected_option_label_input_value, **html_input_options }
|
||||||
- if form
|
- 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
|
- 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
|
.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 } }
|
.sr-only{ aria: { live: 'polite', atomic: 'true' }, data: { turbo_force: :browser } }
|
||||||
%template
|
%template
|
||||||
%li.fr-menu__item{ role: 'option' }
|
%li.fr-menu__item{ role: 'option' }
|
||||||
%slot{ name: 'label' }
|
%slot{ name: 'label' }
|
||||||
|
= content
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
class EditableChamp::AddressComponent < EditableChamp::ComboSearchComponent
|
class EditableChamp::AddressComponent < EditableChamp::EditableChampBaseComponent
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,11 +1,2 @@
|
||||||
- render_parent
|
= 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' }
|
||||||
= @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,
|
|
||||||
)
|
|
||||||
|
|
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;
|
#combobox?: ComboboxUI;
|
||||||
|
|
||||||
connect() {
|
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<
|
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
|
||||||
string,
|
string,
|
||||||
string
|
string
|
||||||
>;
|
>;
|
||||||
this.#combobox = new ComboboxUI({
|
this.#combobox = new ComboboxUI({
|
||||||
input,
|
input,
|
||||||
valueInput,
|
selectedValueInput,
|
||||||
|
valueSlots,
|
||||||
list,
|
list,
|
||||||
item,
|
item,
|
||||||
hint,
|
hint,
|
||||||
|
@ -33,9 +35,12 @@ export class ComboboxController extends ApplicationController {
|
||||||
private getElements() {
|
private getElements() {
|
||||||
const input =
|
const input =
|
||||||
this.element.querySelector<HTMLInputElement>('input[type="text"]');
|
this.element.querySelector<HTMLInputElement>('input[type="text"]');
|
||||||
const valueInput = this.element.querySelector<HTMLInputElement>(
|
const selectedValueInput = this.element.querySelector<HTMLInputElement>(
|
||||||
'input[type="hidden"]'
|
'input[type="hidden"]'
|
||||||
);
|
);
|
||||||
|
const valueSlots = this.element.querySelectorAll<HTMLInputElement>(
|
||||||
|
'input[type="hidden"][data-value-slot]'
|
||||||
|
);
|
||||||
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
|
const list = this.element.querySelector<HTMLUListElement>('[role=listbox]');
|
||||||
const item = this.element.querySelector<HTMLTemplateElement>('template');
|
const item = this.element.querySelector<HTMLTemplateElement>('template');
|
||||||
const hint =
|
const hint =
|
||||||
|
@ -46,7 +51,7 @@ export class ComboboxController extends ApplicationController {
|
||||||
'ComboboxController requires a input element'
|
'ComboboxController requires a input element'
|
||||||
);
|
);
|
||||||
invariant(
|
invariant(
|
||||||
isInputElement(valueInput),
|
isInputElement(selectedValueInput),
|
||||||
'ComboboxController requires a hidden input element'
|
'ComboboxController requires a hidden input element'
|
||||||
);
|
);
|
||||||
invariant(
|
invariant(
|
||||||
|
@ -58,7 +63,7 @@ export class ComboboxController extends ApplicationController {
|
||||||
'ComboboxController requires a template element'
|
'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 { dispatchAction } from '@coldwired/actions';
|
||||||
import { createPopper, Instance as Popper } from '@popperjs/core';
|
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/);
|
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
|
||||||
|
|
||||||
export type ComboboxUIOptions = {
|
export type ComboboxUIOptions = {
|
||||||
input: HTMLInputElement;
|
input: HTMLInputElement;
|
||||||
valueInput: HTMLInputElement;
|
selectedValueInput: HTMLInputElement;
|
||||||
list: HTMLUListElement;
|
list: HTMLUListElement;
|
||||||
item: HTMLTemplateElement;
|
item: HTMLTemplateElement;
|
||||||
|
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
|
||||||
allowsCustomValue?: boolean;
|
allowsCustomValue?: boolean;
|
||||||
hint?: HTMLElement;
|
hint?: HTMLElement;
|
||||||
getHintText?: (hint: Hint) => string;
|
getHintText?: (hint: Hint) => string;
|
||||||
|
@ -25,7 +33,8 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
#isComposing = false;
|
#isComposing = false;
|
||||||
|
|
||||||
#input: HTMLInputElement;
|
#input: HTMLInputElement;
|
||||||
#valueInput: HTMLInputElement;
|
#selectedValueInput: HTMLInputElement;
|
||||||
|
#valueSlots: HTMLInputElement[];
|
||||||
#list: HTMLUListElement;
|
#list: HTMLUListElement;
|
||||||
#item: HTMLTemplateElement;
|
#item: HTMLTemplateElement;
|
||||||
#hint?: HTMLElement;
|
#hint?: HTMLElement;
|
||||||
|
@ -35,7 +44,8 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
input,
|
input,
|
||||||
valueInput,
|
selectedValueInput,
|
||||||
|
valueSlots,
|
||||||
list,
|
list,
|
||||||
item,
|
item,
|
||||||
hint,
|
hint,
|
||||||
|
@ -43,7 +53,8 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
allowsCustomValue
|
allowsCustomValue
|
||||||
}: ComboboxUIOptions) {
|
}: ComboboxUIOptions) {
|
||||||
this.#input = input;
|
this.#input = input;
|
||||||
this.#valueInput = valueInput;
|
this.#selectedValueInput = selectedValueInput;
|
||||||
|
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
|
||||||
this.#list = list;
|
this.#list = list;
|
||||||
this.#item = item;
|
this.#item = item;
|
||||||
this.#hint = hint;
|
this.#hint = hint;
|
||||||
|
@ -52,20 +63,39 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const selectedValue = this.#list.dataset.selected;
|
if (this.#list.dataset.url) {
|
||||||
const options = JSON.parse(this.#list.dataset.options ?? '[]') as Option[];
|
const fetcher = createFetcher(this.#list.dataset.url);
|
||||||
this.#list.removeAttribute('data-options');
|
|
||||||
this.#list.removeAttribute('data-selected');
|
this.#list.removeAttribute('data-url');
|
||||||
const selected =
|
|
||||||
options.find(({ value }) => value == selectedValue) ?? null;
|
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.#combobox.init();
|
||||||
|
|
||||||
this.#input.addEventListener('blur', this);
|
this.#input.addEventListener('blur', this);
|
||||||
|
@ -192,18 +222,20 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
this.#input.value = state.inputValue;
|
this.#input.value = state.inputValue;
|
||||||
}
|
}
|
||||||
this.dispatchChange(() => {
|
this.dispatchChange(() => {
|
||||||
if (this.#valueInput.value != state.inputValue) {
|
if (this.#selectedValueInput.value != state.inputValue) {
|
||||||
if (state.allowsCustomValue || !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 {
|
private renderSelect(state: State): void {
|
||||||
this.dispatchChange(() => {
|
this.dispatchChange(() => {
|
||||||
this.#valueInput.value = state.selection?.value ?? '';
|
this.#selectedValueInput.value = state.selection?.value ?? '';
|
||||||
this.#input.value = state.selection?.label ?? '';
|
this.#input.value = state.selection?.label ?? '';
|
||||||
|
return state.selection?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -252,12 +284,28 @@ export class ComboboxUI implements EventListenerObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchChange(cb: () => void): void {
|
private dispatchChange(cb: () => Option['data']): void {
|
||||||
const value = this.#valueInput.value;
|
const value = this.#selectedValueInput.value;
|
||||||
cb();
|
const data = cb();
|
||||||
if (value != this.#valueInput.value) {
|
if (value != this.#selectedValueInput.value) {
|
||||||
console.debug('combobox change', this.#valueInput.value);
|
for (const input of this.#valueSlots) {
|
||||||
dispatch('change', { target: this.#valueInput });
|
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) {
|
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 {
|
function defaultGetHintText(hint: Hint): string {
|
||||||
|
@ -369,3 +422,37 @@ function defaultGetHintText(hint: Hint): string {
|
||||||
return `${hint.label} selected.`;
|
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 { suite, test, beforeEach, expect } from 'vitest';
|
||||||
|
import { matchSorter } from 'match-sorter';
|
||||||
|
|
||||||
import { Combobox, Option, State } from './combobox';
|
import { Combobox, Option, State } from './combobox';
|
||||||
|
|
||||||
|
@ -26,6 +27,7 @@ suite('Combobox', () => {
|
||||||
|
|
||||||
test('open select box and select option with click', () => {
|
test('open select box and select option with click', () => {
|
||||||
expect(currentState.open).toBeFalsy();
|
expect(currentState.open).toBeFalsy();
|
||||||
|
expect(currentState.loading).toBe(null);
|
||||||
expect(currentState.selection?.label).toBe('Fraises');
|
expect(currentState.selection?.label).toBe('Fraises');
|
||||||
|
|
||||||
combobox.open();
|
combobox.open();
|
||||||
|
@ -262,4 +264,32 @@ suite('Combobox', () => {
|
||||||
expect(currentState.inputValue).toEqual('toto');
|
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',
|
Clear = 'clear',
|
||||||
Update = 'update'
|
Update = 'update'
|
||||||
}
|
}
|
||||||
export type Option = { value: string; label: string };
|
export type Option = { value: string; label: string; data?: unknown };
|
||||||
export type Hint =
|
export type Hint =
|
||||||
| {
|
| {
|
||||||
type: 'results';
|
type: 'results';
|
||||||
|
@ -27,8 +27,14 @@ export type State = {
|
||||||
options: Option[];
|
options: Option[];
|
||||||
allowsCustomValue: boolean;
|
allowsCustomValue: boolean;
|
||||||
hint: Hint | null;
|
hint: Hint | null;
|
||||||
|
loading: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Fetcher = (
|
||||||
|
term: string,
|
||||||
|
options?: { signal: AbortSignal }
|
||||||
|
) => Promise<Option[]>;
|
||||||
|
|
||||||
export class Combobox {
|
export class Combobox {
|
||||||
#allowsCustomValue = false;
|
#allowsCustomValue = false;
|
||||||
#open = false;
|
#open = false;
|
||||||
|
@ -38,27 +44,26 @@ export class Combobox {
|
||||||
#options: Option[] = [];
|
#options: Option[] = [];
|
||||||
#visibleOptions: Option[] = [];
|
#visibleOptions: Option[] = [];
|
||||||
#render: (state: State) => void;
|
#render: (state: State) => void;
|
||||||
|
#fetcher: Fetcher | null;
|
||||||
|
#abortController?: AbortController | null;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
options,
|
options,
|
||||||
selected,
|
selected,
|
||||||
value,
|
|
||||||
allowsCustomValue,
|
allowsCustomValue,
|
||||||
render
|
render
|
||||||
}: {
|
}: {
|
||||||
options: Option[];
|
options: Option[] | Fetcher;
|
||||||
selected: Option | null;
|
selected: Option | null;
|
||||||
value?: string;
|
|
||||||
allowsCustomValue?: boolean;
|
allowsCustomValue?: boolean;
|
||||||
render: (state: State) => void;
|
render: (state: State) => void;
|
||||||
}) {
|
}) {
|
||||||
this.#allowsCustomValue = allowsCustomValue ?? false;
|
this.#allowsCustomValue = allowsCustomValue ?? false;
|
||||||
this.#options = options;
|
this.#options = Array.isArray(options) ? options : [];
|
||||||
|
this.#fetcher = Array.isArray(options) ? null : options;
|
||||||
this.#selectedOption = selected;
|
this.#selectedOption = selected;
|
||||||
if (this.#selectedOption) {
|
if (this.#selectedOption) {
|
||||||
this.#inputValue = this.#selectedOption.label;
|
this.#inputValue = this.#selectedOption.label;
|
||||||
} else if (value) {
|
|
||||||
this.#inputValue = value;
|
|
||||||
}
|
}
|
||||||
this.#render = render;
|
this.#render = render;
|
||||||
}
|
}
|
||||||
|
@ -114,11 +119,28 @@ export class Combobox {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
input(value: string) {
|
async input(value: string) {
|
||||||
if (this.#inputValue == value) return;
|
if (this.#inputValue == value) return;
|
||||||
|
|
||||||
this.#inputValue = value;
|
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.#visibleOptions.length > 0) {
|
||||||
if (!this.#open) {
|
if (!this.#open) {
|
||||||
this.open();
|
this.open();
|
||||||
|
@ -239,7 +261,8 @@ export class Combobox {
|
||||||
focused: this.#focusedOption,
|
focused: this.#focusedOption,
|
||||||
selection: this.#selectedOption,
|
selection: this.#selectedOption,
|
||||||
allowsCustomValue: this.#allowsCustomValue,
|
allowsCustomValue: this.#allowsCustomValue,
|
||||||
hint: null
|
hint: null,
|
||||||
|
loading: this.#abortController ? true : this.#fetcher ? false : null
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...state, hint: this._getFeedback(state) };
|
return { ...state, hint: this._getFeedback(state) };
|
||||||
|
|
|
@ -221,6 +221,10 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
namespace :data_sources do
|
||||||
|
get :adresse, to: 'adresse#search', as: :data_source_adresse
|
||||||
|
end
|
||||||
|
|
||||||
#
|
#
|
||||||
# Deprecated UI
|
# Deprecated UI
|
||||||
#
|
#
|
||||||
|
|
Loading…
Reference in a new issue