diff --git a/app/components/dsfr/combobox_component/combobox_component.en.yml b/app/components/dsfr/combobox_component/combobox_component.en.yml deleted file mode 100644 index e24b49f92..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.en.yml +++ /dev/null @@ -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" diff --git a/app/components/dsfr/combobox_component/combobox_component.fr.yml b/app/components/dsfr/combobox_component/combobox_component.fr.yml deleted file mode 100644 index dc76ad006..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.fr.yml +++ /dev/null @@ -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é" diff --git a/app/components/dsfr/combobox_component/combobox_component.html.haml b/app/components/dsfr/combobox_component/combobox_component.html.haml deleted file mode 100644 index 47dc64b3b..000000000 --- a/app/components/dsfr/combobox_component/combobox_component.html.haml +++ /dev/null @@ -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 diff --git a/app/components/editable_champ/combo_search_component.rb b/app/components/editable_champ/combo_search_component.rb deleted file mode 100644 index bbad7a600..000000000 --- a/app/components/editable_champ/combo_search_component.rb +++ /dev/null @@ -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 diff --git a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml b/app/components/editable_champ/combo_search_component/combo_search_component.html.haml deleted file mode 100644 index 9b2e14a56..000000000 --- a/app/components/editable_champ/combo_search_component/combo_search_component.html.haml +++ /dev/null @@ -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) diff --git a/app/javascript/components/ComboAdresseSearch.tsx b/app/javascript/components/ComboAdresseSearch.tsx deleted file mode 100644 index f6a34a322..000000000 --- a/app/javascript/components/ComboAdresseSearch.tsx +++ /dev/null @@ -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; -type AdresseResult = RawResult['features'][0]; -type ComboAdresseSearchProps = Omit< - ComboSearchProps, - 'minimumInputLength' | 'transformResult' | 'transformResults' | 'scope' ->; - -export default function ComboAdresseSearch({ - allowInputValues = true, - ...props -}: ComboAdresseSearchProps) { - return ( - - - {...props} - allowInputValues={allowInputValues} - scope="adresse" - minimumInputLength={2} - transformResult={({ properties: { label } }) => [label, label, label]} - transformResults={(_, result) => (result as RawResult).features} - debounceDelay={300} - /> - - ); -} diff --git a/app/javascript/components/ComboAnnuaireEducationSearch.tsx b/app/javascript/components/ComboAnnuaireEducationSearch.tsx deleted file mode 100644 index 23fc46ec4..000000000 --- a/app/javascript/components/ComboAnnuaireEducationSearch.tsx +++ /dev/null @@ -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 -) { - return ( - - [id, `${nom_etablissement}, ${nom_commune} (${id})`]} - /> - - ); -} diff --git a/app/javascript/components/ComboMultiple.tsx b/app/javascript/components/ComboMultiple.tsx deleted file mode 100644 index a4206b9f8..000000000 --- a/app/javascript/components/ComboMultiple.tsx +++ /dev/null @@ -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(null); - const [term, setTerm] = useState(''); - const [selections, setSelections] = useState(selected); - const [newValues, setNewValues] = useState([]); - const internalId = useId(); - const inputId = id ?? internalId; - const removedLabelledby = `${inputId}-remove`; - const selectedLabelledby = `${inputId}-selected`; - - const optionsWithLabels = useMemo( - () => - 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 = (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 = (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 ( - - - - désélectionner - -
    - {selections.map((selection) => ( - - {optionLabelByValue(newValues, optionsWithLabels, selection)} - - ))} -
- -
- {results && (results.length > 0 || !acceptNewValues) && ( - - - {results.length === 0 && ( -
  • - Aucun résultat{' '} - -
  • - )} - {results.map(([label, value], index) => { - if (label.startsWith('--')) { - return ; - } - return ( - - {label} - - ); - })} -
    -
    - )} -
    - ); -} - -function ComboboxTokenLabel({ - onRemove, - children -}: { - onRemove: (value: string) => void; - children: ReactNode; -}) { - return ( - -
    {children}
    -
    - ); -} - -function ComboboxSeparator({ value }: { value: string }) { - return ( -
  • - {value.slice(2, -2)} -
  • - ); -} - -function ComboboxToken({ - value, - describedby, - children, - ...props -}: { - value: string; - describedby: string; - children: ReactNode; -}) { - const context = useContext(Context); - invariant(context, 'invalid context'); - const { onRemove } = context; - - return ( -
  • - -
  • - ); -} diff --git a/app/javascript/components/ComboSearch.tsx b/app/javascript/components/ComboSearch.tsx deleted file mode 100644 index 2115d6be6..000000000 --- a/app/javascript/components/ComboSearch.tsx +++ /dev/null @@ -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 = (term: string, results: unknown) => Result[]; -type TransformResult = ( - result: Result -) => [key: string, value: string, label?: string]; - -export type ComboSearchProps = { - onChange?: (value: string | null, result?: Result) => void; - value?: string; - scope: string; - scopeExtra?: string; - minimumInputLength: number; - transformResults?: TransformResults; - transformResult: TransformResult; - 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({ - onChange, - value: controlledValue, - scope, - scopeExtra, - minimumInputLength, - transformResult, - allowInputValues = false, - transformResults = (_, results) => results as Result[], - id, - describedby, - screenReaderInstructions, - announceTemplateId, - debounceDelay = 0, - ...props -}: ComboSearchProps) { - 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 - >({}); - 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 = ({ - 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( - [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>(); - const announceTemplate = document.querySelector( - `#${announceTemplateId}` - ); - invariant(announceTemplate, `Missing #${announceTemplateId}`); - - const announceFragment = useRef( - announceTemplate.content.cloneNode(true) as DocumentFragment - ).current; - - useEffect(() => { - if (isSuccess) { - const slot = announceFragment.querySelector( - 'slot[name="' + (results.length <= 1 ? results.length : 'many') + '"]' - ); - - if (!slot) { - return; - } - - const countSlot = - slot.querySelector('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 ( - - - {isSuccess && ( - - {results.length > 0 ? ( - - {results.map((result, index) => { - const label = getLabel(result); - const [key, value] = transformResult(result); - resultsMap.current[label] = { key, value, result }; - return ; - })} - - ) : ( - - Aucun résultat trouvé - - )} - - )} - {!describedby && ( - - {screenReaderInstructions} - - )} -
    - {announceLive} -
    -
    - ); -} - -export default ComboSearch; diff --git a/app/javascript/components/shared/hooks.ts b/app/javascript/components/shared/hooks.ts deleted file mode 100644 index 3574455d4..000000000 --- a/app/javascript/components/shared/hooks.ts +++ /dev/null @@ -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( - `${group} input[type="hidden"][name$="[${name}]"], ${group} input[type="hidden"][name="${name}"]` - ); - } -} diff --git a/app/javascript/components/shared/queryClient.ts b/app/javascript/components/shared/queryClient.ts deleted file mode 100644 index 700dc595d..000000000 --- a/app/javascript/components/shared/queryClient.ts +++ /dev/null @@ -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 = 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 - } - } -}); diff --git a/app/javascript/controllers/combobox_controller.ts b/app/javascript/controllers/combobox_controller.ts deleted file mode 100644 index 36eddca2c..000000000 --- a/app/javascript/controllers/combobox_controller.ts +++ /dev/null @@ -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('input[type="text"]'); - const selectedValueInput = this.element.querySelector( - 'input[type="hidden"]' - ); - const valueSlots = this.element.querySelectorAll( - 'input[type="hidden"][data-value-slot]' - ); - const list = this.element.querySelector('[role=listbox]'); - const item = this.element.querySelector('template'); - const hint = - this.element.querySelector('[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, 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'; - } -} diff --git a/app/javascript/shared/combobox-ui.ts b/app/javascript/shared/combobox-ui.ts deleted file mode 100644 index c55b7359d..000000000 --- a/app/javascript/shared/combobox-ui.ts +++ /dev/null @@ -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; - 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(`#${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('[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((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); - } - }); -} diff --git a/app/javascript/shared/combobox.test.ts b/app/javascript/shared/combobox.test.ts deleted file mode 100644 index 45633b997..000000000 --- a/app/javascript/shared/combobox.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/app/javascript/shared/combobox.ts b/app/javascript/shared/combobox.ts deleted file mode 100644 index b5c82c524..000000000 --- a/app/javascript/shared/combobox.ts +++ /dev/null @@ -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; - -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; - } -} diff --git a/spec/components/previews/dsfr/combobox_component_preview.rb b/spec/components/previews/dsfr/combobox_component_preview.rb deleted file mode 100644 index 57367fd28..000000000 --- a/spec/components/previews/dsfr/combobox_component_preview.rb +++ /dev/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