refactor(js): remove old code
This commit is contained in:
parent
2f2edfdfc7
commit
4e8b29b21c
16 changed files with 0 additions and 2151 deletions
|
@ -1,10 +0,0 @@
|
|||
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"
|
|
@ -1,10 +0,0 @@
|
|||
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é"
|
|
@ -1,14 +0,0 @@
|
|||
.fr-ds-combobox{ data: { controller: 'combobox', allows_custom_value: allows_custom_value, limit: limit } }
|
||||
.fr-ds-combobox-input
|
||||
%input{ value: selected_option_label_input_value, **html_input_options }
|
||||
- if form
|
||||
= form.hidden_field name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options
|
||||
- else
|
||||
%input{ type: 'hidden', name: name, value: selected_option_value_input_value, form: form_id, **@hidden_html_options }
|
||||
.fr-menu
|
||||
%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,17 +0,0 @@
|
|||
class EditableChamp::ComboSearchComponent < EditableChamp::EditableChampBaseComponent
|
||||
include ApplicationHelper
|
||||
|
||||
def announce_template_id
|
||||
@announce_template_id ||= dom_id(@champ, "aria-announce-template")
|
||||
end
|
||||
|
||||
# NOTE: because this template is called by `render_parent` from a child template,
|
||||
# as of ViewComponent 2.x translations virtual paths are not properly propagated
|
||||
# and we can't use the usual component namespacing. Instead we use global translations.
|
||||
def react_combo_props
|
||||
{
|
||||
screenReaderInstructions: t("combo_search_component.screen_reader_instructions"),
|
||||
announceTemplateId: announce_template_id
|
||||
}
|
||||
end
|
||||
end
|
|
@ -1,4 +0,0 @@
|
|||
%template{ id: announce_template_id }
|
||||
%slot{ "name": "0" }= t("combo_search_component.result_slot_html", count: 0)
|
||||
%slot{ "name": "1" }= t("combo_search_component.result_slot_html", count: 1)
|
||||
%slot{ "name": "many" }= t("combo_search_component.result_slot_html", count: 2)
|
|
@ -1,32 +0,0 @@
|
|||
import React from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
import type { FeatureCollection, Geometry } from 'geojson';
|
||||
|
||||
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
type RawResult = FeatureCollection<Geometry, { label: string }>;
|
||||
type AdresseResult = RawResult['features'][0];
|
||||
type ComboAdresseSearchProps = Omit<
|
||||
ComboSearchProps<AdresseResult>,
|
||||
'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope'
|
||||
>;
|
||||
|
||||
export default function ComboAdresseSearch({
|
||||
allowInputValues = true,
|
||||
...props
|
||||
}: ComboAdresseSearchProps) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch<AdresseResult>
|
||||
{...props}
|
||||
allowInputValues={allowInputValues}
|
||||
scope="adresse"
|
||||
minimumInputLength={2}
|
||||
transformResult={({ properties: { label } }) => [label, label, label]}
|
||||
transformResults={(_, result) => (result as RawResult).features}
|
||||
debounceDelay={300}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import React from 'react';
|
||||
import { QueryClientProvider } from 'react-query';
|
||||
|
||||
import ComboSearch, { ComboSearchProps } from './ComboSearch';
|
||||
import { queryClient } from './shared/queryClient';
|
||||
|
||||
type AnnuaireEducationResult = {
|
||||
fields: {
|
||||
identifiant_de_l_etablissement: string;
|
||||
nom_etablissement: string;
|
||||
nom_commune: string;
|
||||
};
|
||||
};
|
||||
|
||||
function transformResults(_: unknown, result: unknown) {
|
||||
const results = result as { records: AnnuaireEducationResult[] };
|
||||
return results.records as AnnuaireEducationResult[];
|
||||
}
|
||||
|
||||
export default function ComboAnnuaireEducationSearch(
|
||||
props: ComboSearchProps<AnnuaireEducationResult>
|
||||
) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ComboSearch
|
||||
{...props}
|
||||
scope="annuaire-education"
|
||||
minimumInputLength={3}
|
||||
transformResults={transformResults}
|
||||
transformResult={({
|
||||
fields: {
|
||||
identifiant_de_l_etablissement: id,
|
||||
nom_etablissement,
|
||||
nom_commune
|
||||
}
|
||||
}) => [id, `${nom_etablissement}, ${nom_commune} (${id})`]}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
|
@ -1,374 +0,0 @@
|
|||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
useId,
|
||||
ReactNode,
|
||||
ChangeEventHandler,
|
||||
KeyboardEventHandler
|
||||
} from 'react';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxList,
|
||||
ComboboxOption,
|
||||
ComboboxPopover
|
||||
} from '@reach/combobox';
|
||||
import '@reach/combobox/styles.css';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { XIcon } from '@heroicons/react/outline';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { useDeferredSubmit, useHiddenField } from './shared/hooks';
|
||||
|
||||
const Context = createContext<{
|
||||
onRemove: (value: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
type Option = [label: string, value: string];
|
||||
|
||||
function isOptions(options: string[] | Option[]): options is Option[] {
|
||||
return Array.isArray(options[0]);
|
||||
}
|
||||
|
||||
const optionLabelByValue = (
|
||||
values: string[],
|
||||
options: Option[],
|
||||
value: string
|
||||
): string => {
|
||||
const maybeOption: Option | undefined = values.includes(value)
|
||||
? [value, value]
|
||||
: options.find(([, optionValue]) => optionValue == value);
|
||||
return maybeOption ? maybeOption[0] : '';
|
||||
};
|
||||
|
||||
export type ComboMultipleProps = {
|
||||
options: string[] | Option[];
|
||||
id: string;
|
||||
labelledby: string;
|
||||
describedby: string;
|
||||
label: string;
|
||||
group: string;
|
||||
name?: string;
|
||||
selected: string[];
|
||||
acceptNewValues?: boolean;
|
||||
};
|
||||
|
||||
export default function ComboMultiple({
|
||||
options,
|
||||
id,
|
||||
labelledby,
|
||||
describedby,
|
||||
label,
|
||||
group,
|
||||
name = 'value',
|
||||
selected,
|
||||
acceptNewValues = false
|
||||
}: ComboMultipleProps) {
|
||||
invariant(id || label, 'ComboMultiple: `id` or a `label` are required');
|
||||
invariant(group, 'ComboMultiple: `group` is required');
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [term, setTerm] = useState('');
|
||||
const [selections, setSelections] = useState(selected);
|
||||
const [newValues, setNewValues] = useState<string[]>([]);
|
||||
const internalId = useId();
|
||||
const inputId = id ?? internalId;
|
||||
const removedLabelledby = `${inputId}-remove`;
|
||||
const selectedLabelledby = `${inputId}-selected`;
|
||||
|
||||
const optionsWithLabels = useMemo<Option[]>(
|
||||
() =>
|
||||
isOptions(options)
|
||||
? options
|
||||
: options.filter((o) => o).map((o) => [o, o]),
|
||||
[options]
|
||||
);
|
||||
|
||||
const extraOptions = useMemo(
|
||||
() =>
|
||||
acceptNewValues &&
|
||||
term &&
|
||||
term.length > 2 &&
|
||||
!optionLabelByValue(newValues, optionsWithLabels, term)
|
||||
? [[term, term]]
|
||||
: [],
|
||||
[acceptNewValues, term, optionsWithLabels, newValues]
|
||||
);
|
||||
|
||||
const extraListOptions = useMemo(
|
||||
() =>
|
||||
acceptNewValues && term && term.length > 2 && term.includes(';')
|
||||
? term.split(';').map((val) => [val.trim(), val.trim()])
|
||||
: [],
|
||||
[acceptNewValues, term]
|
||||
);
|
||||
|
||||
const results = useMemo(
|
||||
() =>
|
||||
[
|
||||
...extraOptions,
|
||||
...(term
|
||||
? matchSorter(
|
||||
optionsWithLabels.filter(([label]) => !label.startsWith('--')),
|
||||
term
|
||||
)
|
||||
: optionsWithLabels)
|
||||
].filter(([, value]) => !selections.includes(value)),
|
||||
[term, selections, extraOptions, optionsWithLabels]
|
||||
);
|
||||
const [, setHiddenFieldValue, hiddenField] = useHiddenField(group, name);
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setTerm(event.target.value);
|
||||
};
|
||||
|
||||
const saveSelection = (fn: (selections: string[]) => string[]) => {
|
||||
setSelections((selections) => {
|
||||
selections = fn(selections);
|
||||
setHiddenFieldValue(JSON.stringify(selections));
|
||||
return selections;
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
const maybeValue = [...extraOptions, ...optionsWithLabels].find(
|
||||
([, val]) => val == value
|
||||
);
|
||||
|
||||
const maybeValueFromListOptions = extraListOptions.find(
|
||||
([, val]) => val == value
|
||||
);
|
||||
|
||||
const selectedValue =
|
||||
term.includes(';') && acceptNewValues
|
||||
? maybeValueFromListOptions && maybeValueFromListOptions[1]
|
||||
: maybeValue && maybeValue[1];
|
||||
|
||||
if (selectedValue) {
|
||||
if (
|
||||
(acceptNewValues &&
|
||||
extraOptions[0] &&
|
||||
extraOptions[0][0] == selectedValue) ||
|
||||
(acceptNewValues && extraListOptions[0])
|
||||
) {
|
||||
setNewValues((newValues) => {
|
||||
const set = new Set(newValues);
|
||||
set.add(selectedValue);
|
||||
return [...set];
|
||||
});
|
||||
}
|
||||
saveSelection((selections) => {
|
||||
const set = new Set(selections);
|
||||
set.add(selectedValue);
|
||||
return [...set];
|
||||
});
|
||||
}
|
||||
setTerm('');
|
||||
awaitFormSubmit.done();
|
||||
hidePopover();
|
||||
};
|
||||
|
||||
const onRemove = (optionValue: string) => {
|
||||
if (optionValue) {
|
||||
saveSelection((selections) =>
|
||||
selections.filter((value) => value != optionValue)
|
||||
);
|
||||
setNewValues((newValues) =>
|
||||
newValues.filter((value) => value != optionValue)
|
||||
);
|
||||
}
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if (
|
||||
isHotkey('enter', event) ||
|
||||
isHotkey(' ', event) ||
|
||||
isHotkey(',', event) ||
|
||||
isHotkey(';', event)
|
||||
) {
|
||||
if (term.includes(';')) {
|
||||
for (const val of term.split(';')) {
|
||||
event.preventDefault();
|
||||
onSelect(val.trim());
|
||||
}
|
||||
} else if (
|
||||
term &&
|
||||
[...extraOptions, ...optionsWithLabels]
|
||||
.map(([label]) => label)
|
||||
.includes(term)
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSelect(term);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hidePopover = () => {
|
||||
document
|
||||
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
|
||||
?.setAttribute('hidden', 'true');
|
||||
};
|
||||
|
||||
const showPopover = () => {
|
||||
document
|
||||
.querySelector(`[data-reach-combobox-popover-id="${inputId}"]`)
|
||||
?.removeAttribute('hidden');
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
const shouldSelect =
|
||||
term &&
|
||||
[...extraOptions, ...optionsWithLabels]
|
||||
.map(([label]) => label)
|
||||
.includes(term);
|
||||
|
||||
awaitFormSubmit(() => {
|
||||
if (term.includes(';')) {
|
||||
for (const val of term.split(';')) {
|
||||
onSelect(val.trim());
|
||||
}
|
||||
} else if (shouldSelect) {
|
||||
onSelect(term);
|
||||
} else {
|
||||
hidePopover();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox openOnFocus={true} onSelect={onSelect}>
|
||||
<ComboboxTokenLabel onRemove={onRemove}>
|
||||
<span id={removedLabelledby} className="hidden">
|
||||
désélectionner
|
||||
</span>
|
||||
<ul
|
||||
id={selectedLabelledby}
|
||||
aria-live="polite"
|
||||
aria-atomic={true}
|
||||
data-reach-combobox-token-list
|
||||
>
|
||||
{selections.map((selection) => (
|
||||
<ComboboxToken
|
||||
key={selection}
|
||||
value={selection}
|
||||
describedby={removedLabelledby}
|
||||
>
|
||||
{optionLabelByValue(newValues, optionsWithLabels, selection)}
|
||||
</ComboboxToken>
|
||||
))}
|
||||
</ul>
|
||||
<ComboboxInput
|
||||
ref={inputRef}
|
||||
value={term}
|
||||
onChange={handleChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
onClick={showPopover}
|
||||
autocomplete={false}
|
||||
id={inputId}
|
||||
aria-label={label}
|
||||
aria-labelledby={[labelledby, selectedLabelledby]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-describedby={describedby}
|
||||
/>
|
||||
</ComboboxTokenLabel>
|
||||
{results && (results.length > 0 || !acceptNewValues) && (
|
||||
<ComboboxPopover
|
||||
className="shadow-popup"
|
||||
data-reach-combobox-popover-id={inputId}
|
||||
>
|
||||
<ComboboxList>
|
||||
{results.length === 0 && (
|
||||
<li data-reach-combobox-no-results>
|
||||
Aucun résultat{' '}
|
||||
<button
|
||||
onClick={() => {
|
||||
setTerm('');
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
className="button"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{results.map(([label, value], index) => {
|
||||
if (label.startsWith('--')) {
|
||||
return <ComboboxSeparator key={index} value={label} />;
|
||||
}
|
||||
return (
|
||||
<ComboboxOption key={index} value={value}>
|
||||
{label}
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</ComboboxList>
|
||||
</ComboboxPopover>
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxTokenLabel({
|
||||
onRemove,
|
||||
children
|
||||
}: {
|
||||
onRemove: (value: string) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Context.Provider value={{ onRemove }}>
|
||||
<div data-reach-combobox-token-label>{children}</div>
|
||||
</Context.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({ value }: { value: string }) {
|
||||
return (
|
||||
<li aria-disabled="true" role="option" data-reach-combobox-separator>
|
||||
{value.slice(2, -2)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxToken({
|
||||
value,
|
||||
describedby,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
value: string;
|
||||
describedby: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const context = useContext(Context);
|
||||
invariant(context, 'invalid context');
|
||||
const { onRemove } = context;
|
||||
|
||||
return (
|
||||
<li data-reach-combobox-token {...props}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRemove(value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
onRemove(value);
|
||||
}
|
||||
}}
|
||||
aria-describedby={describedby}
|
||||
>
|
||||
<XIcon className="icon-size mr-1" aria-hidden="true" />
|
||||
{children}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
|
@ -1,222 +0,0 @@
|
|||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useId,
|
||||
ChangeEventHandler
|
||||
} from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useQuery } from 'react-query';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxPopover,
|
||||
ComboboxList,
|
||||
ComboboxOption
|
||||
} from '@reach/combobox';
|
||||
import '@reach/combobox/styles.css';
|
||||
import invariant from 'tiny-invariant';
|
||||
|
||||
import { useDeferredSubmit, useHiddenField, groupId } from './shared/hooks';
|
||||
|
||||
type TransformResults<Result> = (term: string, results: unknown) => Result[];
|
||||
type TransformResult<Result> = (
|
||||
result: Result
|
||||
) => [key: string, value: string, label?: string];
|
||||
|
||||
export type ComboSearchProps<Result = unknown> = {
|
||||
onChange?: (value: string | null, result?: Result) => void;
|
||||
value?: string;
|
||||
scope: string;
|
||||
scopeExtra?: string;
|
||||
minimumInputLength: number;
|
||||
transformResults?: TransformResults<Result>;
|
||||
transformResult: TransformResult<Result>;
|
||||
allowInputValues?: boolean;
|
||||
id?: string;
|
||||
describedby?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
debounceDelay?: number;
|
||||
screenReaderInstructions: string;
|
||||
announceTemplateId: string;
|
||||
};
|
||||
|
||||
type QueryKey = readonly [
|
||||
scope: string,
|
||||
term: string,
|
||||
extra: string | undefined
|
||||
];
|
||||
|
||||
function ComboSearch<Result>({
|
||||
onChange,
|
||||
value: controlledValue,
|
||||
scope,
|
||||
scopeExtra,
|
||||
minimumInputLength,
|
||||
transformResult,
|
||||
allowInputValues = false,
|
||||
transformResults = (_, results) => results as Result[],
|
||||
id,
|
||||
describedby,
|
||||
screenReaderInstructions,
|
||||
announceTemplateId,
|
||||
debounceDelay = 0,
|
||||
...props
|
||||
}: ComboSearchProps<Result>) {
|
||||
invariant(id || onChange, 'ComboSearch: `id` or `onChange` are required');
|
||||
|
||||
const group = !onChange && id ? groupId(id) : undefined;
|
||||
const [externalValue, setExternalValue, hiddenField] = useHiddenField(group);
|
||||
const [, setExternalId] = useHiddenField(group, 'external_id');
|
||||
const initialValue = externalValue ? externalValue : controlledValue;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm] = useDebounce(searchTerm, debounceDelay);
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const resultsMap = useRef<
|
||||
Record<string, { key: string; value: string; result: Result }>
|
||||
>({});
|
||||
const getLabel = (result: Result) => {
|
||||
const [, value, label] = transformResult(result);
|
||||
return label ?? value;
|
||||
};
|
||||
const setExternalValueAndId = (label: string) => {
|
||||
const { key, value, result } = resultsMap.current[label];
|
||||
if (onChange) {
|
||||
onChange(value, result);
|
||||
} else {
|
||||
setExternalId(key);
|
||||
setExternalValue(value);
|
||||
}
|
||||
};
|
||||
const awaitFormSubmit = useDeferredSubmit(hiddenField);
|
||||
|
||||
const handleOnChange: ChangeEventHandler<HTMLInputElement> = ({
|
||||
target: { value }
|
||||
}) => {
|
||||
setValue(value);
|
||||
if (!value) {
|
||||
if (onChange) {
|
||||
onChange(null);
|
||||
} else {
|
||||
setExternalId('');
|
||||
setExternalValue('');
|
||||
}
|
||||
} else if (value.length >= minimumInputLength) {
|
||||
setSearchTerm(value.trim());
|
||||
if (allowInputValues) {
|
||||
setExternalId('');
|
||||
setExternalValue(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnSelect = (value: string) => {
|
||||
setExternalValueAndId(value);
|
||||
setValue(value);
|
||||
setSearchTerm('');
|
||||
awaitFormSubmit.done();
|
||||
};
|
||||
|
||||
const { isSuccess, data } = useQuery<void, void, unknown, QueryKey>(
|
||||
[scope, debouncedSearchTerm, scopeExtra],
|
||||
{
|
||||
enabled: !!debouncedSearchTerm,
|
||||
refetchOnMount: false
|
||||
}
|
||||
);
|
||||
const results =
|
||||
isSuccess && data ? transformResults(debouncedSearchTerm, data) : [];
|
||||
|
||||
const onBlur = () => {
|
||||
if (!allowInputValues && isSuccess && results[0]) {
|
||||
const label = getLabel(results[0]);
|
||||
awaitFormSubmit(() => {
|
||||
handleOnSelect(label);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [announceLive, setAnnounceLive] = useState('');
|
||||
const announceTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const announceTemplate = document.querySelector<HTMLTemplateElement>(
|
||||
`#${announceTemplateId}`
|
||||
);
|
||||
invariant(announceTemplate, `Missing #${announceTemplateId}`);
|
||||
|
||||
const announceFragment = useRef(
|
||||
announceTemplate.content.cloneNode(true) as DocumentFragment
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
const slot = announceFragment.querySelector<HTMLSlotElement>(
|
||||
'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]'
|
||||
);
|
||||
|
||||
if (!slot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const countSlot =
|
||||
slot.querySelector<HTMLSlotElement>('slot[name="count"]');
|
||||
if (countSlot) {
|
||||
countSlot.replaceWith(String(results.length));
|
||||
}
|
||||
|
||||
setAnnounceLive(slot.textContent ?? '');
|
||||
}
|
||||
|
||||
announceTimeout.current = setTimeout(() => {
|
||||
setAnnounceLive('');
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(announceTimeout.current);
|
||||
}, [announceFragment, results.length, isSuccess]);
|
||||
|
||||
const initInstrId = useId();
|
||||
const resultsId = useId();
|
||||
|
||||
return (
|
||||
<Combobox onSelect={handleOnSelect}>
|
||||
<ComboboxInput
|
||||
{...props}
|
||||
onChange={handleOnChange}
|
||||
onBlur={onBlur}
|
||||
value={value ?? ''}
|
||||
autocomplete={false}
|
||||
id={id}
|
||||
aria-describedby={describedby ?? initInstrId}
|
||||
aria-owns={resultsId}
|
||||
/>
|
||||
{isSuccess && (
|
||||
<ComboboxPopover id={resultsId} className="shadow-popup">
|
||||
{results.length > 0 ? (
|
||||
<ComboboxList>
|
||||
{results.map((result, index) => {
|
||||
const label = getLabel(result);
|
||||
const [key, value] = transformResult(result);
|
||||
resultsMap.current[label] = { key, value, result };
|
||||
return <ComboboxOption key={`${key}-${index}`} value={label} />;
|
||||
})}
|
||||
</ComboboxList>
|
||||
) : (
|
||||
<span style={{ display: 'block', margin: 8 }}>
|
||||
Aucun résultat trouvé
|
||||
</span>
|
||||
)}
|
||||
</ComboboxPopover>
|
||||
)}
|
||||
{!describedby && (
|
||||
<span id={initInstrId} className="hidden">
|
||||
{screenReaderInstructions}
|
||||
</span>
|
||||
)}
|
||||
<div aria-live="assertive" className="sr-only">
|
||||
{announceLive}
|
||||
</div>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComboSearch;
|
|
@ -1,90 +0,0 @@
|
|||
import { useRef, useCallback, useMemo, useState } from 'react';
|
||||
import { fire } from '@utils';
|
||||
|
||||
export function useDeferredSubmit(input?: HTMLInputElement): {
|
||||
(callback: () => void): void;
|
||||
done: () => void;
|
||||
} {
|
||||
const calledRef = useRef(false);
|
||||
const awaitFormSubmit = useCallback(
|
||||
(callback: () => void) => {
|
||||
const form = input?.form;
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const interceptFormSubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
runCallback();
|
||||
|
||||
if (
|
||||
!Array.from(form.elements).some(
|
||||
(e) =>
|
||||
e.hasAttribute('data-direct-upload-url') &&
|
||||
'value' in e &&
|
||||
e.value != ''
|
||||
)
|
||||
) {
|
||||
form.submit();
|
||||
}
|
||||
// else: form will be submitted by diret upload once file have been uploaded
|
||||
};
|
||||
calledRef.current = false;
|
||||
form.addEventListener('submit', interceptFormSubmit);
|
||||
const runCallback = () => {
|
||||
form.removeEventListener('submit', interceptFormSubmit);
|
||||
clearTimeout(timer);
|
||||
if (!calledRef.current) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(runCallback, 400);
|
||||
},
|
||||
[input]
|
||||
);
|
||||
const done = () => {
|
||||
calledRef.current = true;
|
||||
};
|
||||
return Object.assign(awaitFormSubmit, { done });
|
||||
}
|
||||
|
||||
export function groupId(id: string) {
|
||||
return `#${id.replace(/-input$/, '')}`;
|
||||
}
|
||||
|
||||
export function useHiddenField(
|
||||
group?: string,
|
||||
name = 'value'
|
||||
): [
|
||||
value: string | undefined,
|
||||
setValue: (value: string) => void,
|
||||
input: HTMLInputElement | undefined
|
||||
] {
|
||||
const hiddenField = useMemo(
|
||||
() => selectInputInGroup(group, name),
|
||||
[group, name]
|
||||
);
|
||||
const [value, setValue] = useState(() => hiddenField?.value);
|
||||
|
||||
return [
|
||||
value,
|
||||
(value) => {
|
||||
if (hiddenField) {
|
||||
hiddenField.setAttribute('value', value);
|
||||
setValue(value);
|
||||
fire(hiddenField, 'change');
|
||||
}
|
||||
},
|
||||
hiddenField ?? undefined
|
||||
];
|
||||
}
|
||||
|
||||
function selectInputInGroup(
|
||||
group: string | undefined,
|
||||
name: string
|
||||
): HTMLInputElement | undefined | null {
|
||||
if (group) {
|
||||
return document.querySelector<HTMLInputElement>(
|
||||
`${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import { QueryClient, QueryFunction } from 'react-query';
|
||||
import { httpRequest, getConfig } from '@utils';
|
||||
|
||||
const API_EDUCATION_QUERY_LIMIT = 5;
|
||||
const API_ADRESSE_QUERY_LIMIT = 5;
|
||||
|
||||
const {
|
||||
autocomplete: { api_adresse_url, api_education_url }
|
||||
} = getConfig();
|
||||
|
||||
type QueryKey = readonly [
|
||||
scope: string,
|
||||
term: string,
|
||||
extra: string | undefined
|
||||
];
|
||||
|
||||
function buildURL(scope: string, term: string) {
|
||||
term = term.replace(/\(|\)/g, '');
|
||||
const params = new URLSearchParams();
|
||||
let path = '';
|
||||
|
||||
if (scope == 'adresse') {
|
||||
path = `${api_adresse_url}/search`;
|
||||
params.set('q', term);
|
||||
params.set('limit', `${API_ADRESSE_QUERY_LIMIT}`);
|
||||
} else if (scope == 'annuaire-education') {
|
||||
path = `${api_education_url}/search`;
|
||||
params.set('q', term);
|
||||
params.set('rows', `${API_EDUCATION_QUERY_LIMIT}`);
|
||||
params.set('dataset', 'fr-en-annuaire-education');
|
||||
}
|
||||
|
||||
return `${path}?${params}`;
|
||||
}
|
||||
|
||||
const defaultQueryFn: QueryFunction<unknown, QueryKey> = async ({
|
||||
queryKey: [scope, term],
|
||||
signal
|
||||
}) => {
|
||||
// BAN will error with queries less then 3 chars long
|
||||
if (scope == 'adresse' && term.length < 3) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
version: 'draft',
|
||||
features: [],
|
||||
query: term
|
||||
};
|
||||
}
|
||||
|
||||
const url = buildURL(scope, term);
|
||||
return httpRequest(url, { csrf: false, signal }).json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// we don't really care about global queryFn type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryFn: defaultQueryFn as any
|
||||
}
|
||||
}
|
||||
});
|
|
@ -1,99 +0,0 @@
|
|||
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, selectedValueInput, valueSlots, list, item, hint } =
|
||||
this.getElements();
|
||||
const hints = JSON.parse(list.dataset.hints ?? '{}') as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
this.#combobox = new ComboboxUI({
|
||||
input,
|
||||
selectedValueInput,
|
||||
valueSlots,
|
||||
list,
|
||||
item,
|
||||
hint,
|
||||
allowsCustomValue: this.element.hasAttribute('data-allows-custom-value'),
|
||||
limit: this.element.hasAttribute('data-limit')
|
||||
? Number(this.element.getAttribute('data-limit'))
|
||||
: undefined,
|
||||
getHintText: (hint) => getHintText(hints, hint)
|
||||
});
|
||||
this.#combobox.init();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#combobox?.destroy();
|
||||
}
|
||||
|
||||
private getElements() {
|
||||
const input =
|
||||
this.element.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
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 =
|
||||
this.element.querySelector<HTMLElement>('[aria-live]') ?? undefined;
|
||||
|
||||
invariant(
|
||||
isInputElement(input),
|
||||
'ComboboxController requires a input element'
|
||||
);
|
||||
invariant(
|
||||
isInputElement(selectedValueInput),
|
||||
'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, selectedValueInput, valueSlots, 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';
|
||||
}
|
||||
}
|
|
@ -1,470 +0,0 @@
|
|||
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,
|
||||
Action,
|
||||
type State,
|
||||
type Option,
|
||||
type Hint,
|
||||
type Fetcher
|
||||
} from './combobox';
|
||||
|
||||
const ctrlBindings = !!navigator.userAgent.match(/Macintosh/);
|
||||
|
||||
export type ComboboxUIOptions = {
|
||||
input: HTMLInputElement;
|
||||
selectedValueInput: HTMLInputElement;
|
||||
list: HTMLUListElement;
|
||||
item: HTMLTemplateElement;
|
||||
valueSlots?: HTMLInputElement[] | NodeListOf<HTMLInputElement>;
|
||||
allowsCustomValue?: boolean;
|
||||
limit?: number;
|
||||
hint?: HTMLElement;
|
||||
getHintText?: (hint: Hint) => string;
|
||||
};
|
||||
|
||||
export class ComboboxUI implements EventListenerObject {
|
||||
#combobox?: Combobox;
|
||||
#popper?: Popper;
|
||||
#interactingWithList = false;
|
||||
#mouseOverList = false;
|
||||
#isComposing = false;
|
||||
|
||||
#input: HTMLInputElement;
|
||||
#selectedValueInput: HTMLInputElement;
|
||||
#valueSlots: HTMLInputElement[];
|
||||
#list: HTMLUListElement;
|
||||
#item: HTMLTemplateElement;
|
||||
#hint?: HTMLElement;
|
||||
|
||||
#getHintText = defaultGetHintText;
|
||||
#allowsCustomValue: boolean;
|
||||
#limit?: number;
|
||||
|
||||
#selectedData: Option['data'] = null;
|
||||
|
||||
constructor({
|
||||
input,
|
||||
selectedValueInput,
|
||||
valueSlots,
|
||||
list,
|
||||
item,
|
||||
hint,
|
||||
getHintText,
|
||||
allowsCustomValue,
|
||||
limit
|
||||
}: ComboboxUIOptions) {
|
||||
this.#input = input;
|
||||
this.#selectedValueInput = selectedValueInput;
|
||||
this.#valueSlots = valueSlots ? Array.from(valueSlots) : [];
|
||||
this.#list = list;
|
||||
this.#item = item;
|
||||
this.#hint = hint;
|
||||
this.#getHintText = getHintText ?? defaultGetHintText;
|
||||
this.#allowsCustomValue = allowsCustomValue ?? false;
|
||||
this.#limit = limit;
|
||||
}
|
||||
|
||||
init() {
|
||||
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,
|
||||
limit: this.#limit,
|
||||
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,
|
||||
limit: this.#limit,
|
||||
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.#selectedValueInput.value != state.inputValue) {
|
||||
if (state.allowsCustomValue || !state.inputValue) {
|
||||
this.#selectedValueInput.value = state.inputValue;
|
||||
}
|
||||
}
|
||||
return state.selection?.data;
|
||||
});
|
||||
}
|
||||
|
||||
private renderSelect(state: State): void {
|
||||
this.dispatchChange(() => {
|
||||
this.#selectedValueInput.value = state.selection?.value ?? '';
|
||||
this.#input.value = state.selection?.label ?? '';
|
||||
return state.selection?.data;
|
||||
});
|
||||
}
|
||||
|
||||
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: () => Option['data']): void {
|
||||
const value = this.#selectedValueInput.value;
|
||||
const data = cb();
|
||||
if (value != this.#selectedValueInput.value || data != this.#selectedData) {
|
||||
this.#selectedData = data;
|
||||
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:string':
|
||||
input.value = data ? String(data) : '';
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
.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 {
|
||||
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.`;
|
||||
}
|
||||
}
|
||||
|
||||
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,295 +0,0 @@
|
|||
import { suite, test, beforeEach, expect } from 'vitest';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
|
||||
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.loading).toBe(null);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,300 +0,0 @@
|
|||
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; data?: unknown };
|
||||
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;
|
||||
loading: boolean | null;
|
||||
};
|
||||
|
||||
export type Fetcher = (
|
||||
term: string,
|
||||
options?: { signal: AbortSignal }
|
||||
) => Promise<Option[]>;
|
||||
|
||||
export class Combobox {
|
||||
#allowsCustomValue = false;
|
||||
#limit?: number;
|
||||
#open = false;
|
||||
#inputValue = '';
|
||||
#selectedOption: Option | null = null;
|
||||
#focusedOption: Option | null = null;
|
||||
#options: Option[] = [];
|
||||
#visibleOptions: Option[] = [];
|
||||
#render: (state: State) => void;
|
||||
#fetcher: Fetcher | null;
|
||||
#abortController?: AbortController | null;
|
||||
|
||||
constructor({
|
||||
options,
|
||||
selected,
|
||||
allowsCustomValue,
|
||||
limit,
|
||||
render
|
||||
}: {
|
||||
options: Option[] | Fetcher;
|
||||
selected: Option | null;
|
||||
allowsCustomValue?: boolean;
|
||||
limit?: number;
|
||||
render: (state: State) => void;
|
||||
}) {
|
||||
this.#allowsCustomValue = allowsCustomValue ?? false;
|
||||
this.#limit = limit;
|
||||
this.#options = Array.isArray(options) ? options : [];
|
||||
this.#fetcher = Array.isArray(options) ? null : options;
|
||||
this.#selectedOption = selected;
|
||||
if (this.#selectedOption) {
|
||||
this.#inputValue = this.#selectedOption.label;
|
||||
}
|
||||
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.trim() == maybeValue.trim()
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
async input(value: string) {
|
||||
if (this.#inputValue == value) return;
|
||||
|
||||
this.#inputValue = value;
|
||||
|
||||
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;
|
||||
} else {
|
||||
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);
|
||||
} else {
|
||||
this._render(Action.Update);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
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[] {
|
||||
const emptyOrSelected =
|
||||
!this.#inputValue || this.#inputValue == this.#selectedOption?.value;
|
||||
const options = emptyOrSelected
|
||||
? this.#options
|
||||
: matchSorter(this.#options, this.#inputValue, {
|
||||
keys: ['label']
|
||||
});
|
||||
|
||||
if (this.#limit) {
|
||||
return options.slice(0, this.#limit);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
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,
|
||||
loading: this.#abortController ? true : this.#fetcher ? false : 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,112 +0,0 @@
|
|||
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(options: OPTIONS, selected: OPTIONS.sample, input_html_options: { name: :value, id: 'simple-select', class: 'width-33' })
|
||||
end
|
||||
|
||||
def simple_select_with_options_and_allows_custom_value
|
||||
render Dsfr::ComboboxComponent.new(options: OPTIONS, selected: OPTIONS.sample, allows_custom_value: true, input_html_options: { id: 'simple-select', class: 'width-33', name: :value })
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue