refactor(js): remove old code

This commit is contained in:
Paul Chavard 2024-05-06 18:11:25 +02:00
parent 2f2edfdfc7
commit 4e8b29b21c
No known key found for this signature in database
16 changed files with 0 additions and 2151 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}"]`
);
}
}

View file

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

View file

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

View file

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

View file

@ -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 daçaï'
.split(',')
.map((label) => ({ label, value: label }));
let combobox: Combobox;
let currentState: State;
suite('single select without custom value', () => {
suite('with default selection', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: options.at(0) ?? null,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('open select box and select option with click', () => {
expect(currentState.open).toBeFalsy();
expect(currentState.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 daçaï');
});
test('type exact match and press tab', () => {
combobox.input('Baies');
expect(currentState.open).toBeTruthy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection?.label).toBe('Baies daçaï');
expect(currentState.inputValue).toEqual('Baies daçaï');
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('');
});
test('focus should circle', () => {
combobox.input('Baie');
expect(currentState.open).toBeTruthy();
expect(currentState.options.map(({ label }) => label)).toEqual([
'Baies daçaï',
'Baies de genièvre',
'Baies de sureau'
]);
expect(currentState.focused).toBeNull();
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de genièvre');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies de sureau');
combobox.keyboard('ArrowDown');
expect(currentState.focused?.label).toBe('Baies daçaï');
combobox.keyboard('ArrowUp');
expect(currentState.focused?.label).toBe('Baies de sureau');
});
});
});
suite('single select with custom value', () => {
beforeEach(() => {
combobox = new Combobox({
options,
selected: null,
allowsCustomValue: true,
render: (state) => {
currentState = state;
}
});
combobox.init();
});
test('type non matching input and press enter', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Enter');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and press tab', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.keyboard('Tab');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
test('type non matching input and close', () => {
combobox.input('toto');
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
combobox.close();
expect(currentState.open).toBeFalsy();
expect(currentState.selection).toBeNull();
expect(currentState.inputValue).toEqual('toto');
});
});
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);
});
});
});

View file

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

View file

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