-
{
- fire(document, 'map:zoom', {
- featureCollection: comboProps.featureCollection,
- feature
- });
+
+ {
+ if (item && item.data) {
+ fire(document, 'map:zoom', {
+ featureCollection,
+ feature: item.data
+ });
+ }
}}
- {...comboProps}
/>
);
diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx
index 68be216e2..28122dba5 100644
--- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx
+++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
+import { useState, useCallback, MouseEvent, ChangeEvent } from 'react';
import type { FeatureCollection } from 'geojson';
import invariant from 'tiny-invariant';
diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx
index 4f2fb4ead..5cd2e342f 100644
--- a/app/javascript/components/MapEditor/components/PointInput.tsx
+++ b/app/javascript/components/MapEditor/components/PointInput.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useId } from 'react';
+import { useState, useId } from 'react';
import { fire } from '@utils';
import type { Feature, FeatureCollection } from 'geojson';
import CoordinateInput from 'react-coordinate-input';
diff --git a/app/javascript/components/MapEditor/index.tsx b/app/javascript/components/MapEditor/index.tsx
index 0a918c981..5bea4796c 100644
--- a/app/javascript/components/MapEditor/index.tsx
+++ b/app/javascript/components/MapEditor/index.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { CursorClickIcon } from '@heroicons/react/outline';
import 'maplibre-gl/dist/maplibre-gl.css';
import '@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css';
@@ -12,21 +12,18 @@ import { AddressInput } from './components/AddressInput';
import { PointInput } from './components/PointInput';
import { ImportFileInput } from './components/ImportFileInput';
import { FlashMessage } from '../shared/FlashMessage';
-import { ComboSearchProps } from '../ComboSearch';
export default function MapEditor({
featureCollection: initialFeatureCollection,
url,
+ adresseSource,
options,
- autocompleteAnnounceTemplateId,
- autocompleteScreenReaderInstructions,
champId
}: {
featureCollection: FeatureCollection;
url: string;
+ adresseSource: string;
options: { layers: string[] };
- autocompleteAnnounceTemplateId: ComboSearchProps['announceTemplateId'];
- autocompleteScreenReaderInstructions: ComboSearchProps['screenReaderInstructions'];
champId: string;
}) {
const [cadastreEnabled, setCadastreEnabled] = useState(false);
@@ -41,15 +38,10 @@ export default function MapEditor({
{error && }
-
diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
index add1dca03..807730cb3 100644
--- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
+++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl';
import type { Feature, FeatureCollection, Point } from 'geojson';
diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx
index 1f9fb07d4..a8662f152 100644
--- a/app/javascript/components/MapReader/index.tsx
+++ b/app/javascript/components/MapReader/index.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { FeatureCollection } from 'geojson';
diff --git a/app/javascript/components/react-aria/hooks.ts b/app/javascript/components/react-aria/hooks.ts
new file mode 100644
index 000000000..e00bff289
--- /dev/null
+++ b/app/javascript/components/react-aria/hooks.ts
@@ -0,0 +1,486 @@
+import type {
+ ComboBoxProps as AriaComboBoxProps,
+ TagGroupProps
+} from 'react-aria-components';
+import { useAsyncList, type AsyncListOptions } from 'react-stately';
+import { useMemo, useRef, useState, useEffect } from 'react';
+import type { Key } from 'react';
+import { matchSorter } from 'match-sorter';
+import { useDebounceCallback } from 'usehooks-ts';
+import { useEvent } from 'react-use-event-hook';
+import isEqual from 'react-fast-compare';
+import * as s from 'superstruct';
+
+import { Item } from './props';
+
+export type Loader = AsyncListOptions- ['load'];
+
+export interface ComboBoxProps
+ extends Omit, 'children'> {
+ children: React.ReactNode | ((item: Item) => React.ReactNode);
+ label?: string;
+ description?: string;
+}
+
+const inputMap = new WeakMap();
+export function useDispatchChangeEvent() {
+ const ref = useRef(null);
+
+ return {
+ ref,
+ dispatch: () => {
+ requestAnimationFrame(() => {
+ const input = ref.current?.querySelector('input');
+ if (input) {
+ const value = input.value;
+ const prevValue = inputMap.get(input) || '';
+ if (value != prevValue) {
+ inputMap.set(input, value);
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ }
+ });
+ }
+ };
+}
+
+export function useSingleList({
+ defaultItems,
+ defaultSelectedKey,
+ emptyFilterKey,
+ onChange
+}: {
+ defaultItems?: Item[];
+ defaultSelectedKey?: string | null;
+ emptyFilterKey?: string | null;
+ onChange?: (item: Item | null) => void;
+}) {
+ const [selectedKey, setSelectedKey] = useState(defaultSelectedKey);
+ const items = useMemo(
+ () => (defaultItems ? distinctBy(defaultItems, 'value') : []),
+ [defaultItems]
+ );
+ const selectedItem = useMemo(
+ () => items.find((item) => item.value == selectedKey) ?? null,
+ [items, selectedKey]
+ );
+ const [inputValue, setInputValue] = useState(() => selectedItem?.label ?? '');
+ // show fallback item when input value is not matching any items
+ const fallbackItem = useMemo(
+ () => items.find((item) => item.value == emptyFilterKey),
+ [items, emptyFilterKey]
+ );
+ const filteredItems = useMemo(() => {
+ if (inputValue == '') {
+ return items;
+ }
+ const filteredItems = matchSorter(items, inputValue, { keys: ['label'] });
+ if (filteredItems.length == 0 && fallbackItem) {
+ return [fallbackItem];
+ } else {
+ return filteredItems;
+ }
+ }, [items, inputValue, fallbackItem]);
+
+ const initialSelectedKeyRef = useRef(defaultSelectedKey);
+
+ const setSelection = useEvent((key?: string | null) => {
+ const inputValue = key
+ ? items.find((item) => item.value == key)?.label
+ : '';
+ setSelectedKey(key);
+ setInputValue(inputValue ?? '');
+ });
+ const onSelectionChange = useEvent<
+ NonNullable
+ >((key) => {
+ setSelection(key ? String(key) : null);
+ const item =
+ typeof key != 'string'
+ ? null
+ : selectedItem?.value == key
+ ? selectedItem
+ : items.find((item) => item.value == key) ?? null;
+ onChange?.(item);
+ });
+ const onInputChange = useEvent>(
+ (value) => {
+ setInputValue(value);
+ if (value == '') {
+ onSelectionChange(null);
+ }
+ }
+ );
+ const onReset = useEvent(() => {
+ setSelectedKey(null);
+ setInputValue('');
+ });
+
+ // reset default selected key when props change
+ useEffect(() => {
+ if (initialSelectedKeyRef.current != defaultSelectedKey) {
+ initialSelectedKeyRef.current = defaultSelectedKey;
+ setSelection(defaultSelectedKey);
+ }
+ }, [defaultSelectedKey, setSelection]);
+
+ return {
+ selectedItem,
+ selectedKey,
+ onSelectionChange,
+ inputValue,
+ onInputChange,
+ items: filteredItems,
+ onReset
+ };
+}
+
+export function useMultiList({
+ defaultItems,
+ defaultSelectedKeys,
+ allowsCustomValue,
+ valueSeparator,
+ onChange,
+ focusInput,
+ formValue
+}: {
+ defaultItems?: Item[];
+ defaultSelectedKeys?: string[];
+ allowsCustomValue?: boolean;
+ valueSeparator?: string;
+ onChange?: () => void;
+ focusInput?: () => void;
+ formValue?: 'text' | 'key';
+}) {
+ const valueSeparatorRegExp = useMemo(
+ () => (valueSeparator ? new RegExp(valueSeparator) : /\s|,|;/),
+ [valueSeparator]
+ );
+ const [selectedKeys, setSelectedKeys] = useState(
+ () => new Set(defaultSelectedKeys ?? [])
+ );
+ const [inputValue, setInputValue] = useState('');
+ const items = useMemo(
+ () => (defaultItems ? distinctBy(defaultItems, 'value') : []),
+ [defaultItems]
+ );
+ const itemsIndex = useMemo(() => {
+ const index = new Map();
+ for (const item of items) {
+ index.set(item.value, item);
+ }
+ return index;
+ }, [items]);
+ const filteredItems = useMemo(
+ () =>
+ inputValue.length == 0
+ ? items
+ : matchSorter(items, inputValue, { keys: ['label'] }),
+ [items, inputValue]
+ );
+ const selectedItems = useMemo(() => {
+ const selectedItems: Item[] = [];
+ for (const key of selectedKeys) {
+ const item = itemsIndex.get(key);
+ if (item) {
+ selectedItems.push(item);
+ } else if (allowsCustomValue) {
+ selectedItems.push({ label: key, value: key });
+ }
+ }
+ return selectedItems;
+ }, [itemsIndex, selectedKeys, allowsCustomValue]);
+ const hiddenInputValues = useMemo(() => {
+ const values = selectedItems.map((item) =>
+ formValue == 'text' || allowsCustomValue ? item.label : item.value
+ );
+ if (!allowsCustomValue || inputValue == '') {
+ return values;
+ }
+ return [
+ ...new Set([
+ ...values,
+ ...inputValue.split(valueSeparatorRegExp).filter(Boolean)
+ ])
+ ];
+ }, [
+ selectedItems,
+ inputValue,
+ valueSeparatorRegExp,
+ allowsCustomValue,
+ formValue
+ ]);
+ const isSelectionSetRef = useRef(false);
+ const initialSelectedKeysRef = useRef(defaultSelectedKeys);
+
+ // reset default selected keys when props change
+ useEffect(() => {
+ if (!isEqual(initialSelectedKeysRef.current, defaultSelectedKeys)) {
+ initialSelectedKeysRef.current = defaultSelectedKeys;
+ setSelectedKeys(new Set(defaultSelectedKeys));
+ }
+ }, [defaultSelectedKeys]);
+
+ const onSelectionChange = useEvent<
+ NonNullable
+ >((key) => {
+ if (key) {
+ isSelectionSetRef.current = true;
+ setSelectedKeys((keys) => {
+ const selectedKeys = new Set(keys.values());
+ selectedKeys.add(String(key));
+ return selectedKeys;
+ });
+ setInputValue('');
+ onChange?.();
+ }
+ });
+
+ const onInputChange = useEvent>(
+ (value) => {
+ const isSelectionSet = isSelectionSetRef.current;
+ isSelectionSetRef.current = false;
+ if (isSelectionSet) {
+ setInputValue('');
+ return;
+ }
+ if (allowsCustomValue) {
+ const values = value.split(valueSeparatorRegExp);
+ // if input contains a separator, add all values
+ if (values.length > 1) {
+ const addedKeys = values.filter(Boolean);
+ setSelectedKeys((keys) => {
+ const selectedKeys = new Set(keys.values());
+ for (const key of addedKeys) {
+ selectedKeys.add(key);
+ }
+ return selectedKeys;
+ });
+ setInputValue('');
+ } else {
+ setInputValue(value);
+ }
+ onChange?.();
+ } else {
+ setInputValue(value);
+ }
+ }
+ );
+
+ const onRemove = useEvent>(
+ (removedKeys) => {
+ setSelectedKeys((keys) => {
+ const selectedKeys = new Set(keys.values());
+ for (const key of removedKeys) {
+ selectedKeys.delete(String(key));
+ }
+ // focus input when all items are removed
+ if (selectedKeys.size == 0) {
+ focusInput?.();
+ }
+ return selectedKeys;
+ });
+ onChange?.();
+ }
+ );
+
+ const onReset = useEvent(() => {
+ setSelectedKeys(new Set());
+ setInputValue('');
+ });
+
+ return {
+ onRemove,
+ onSelectionChange,
+ onInputChange,
+ selectedItems,
+ items: filteredItems,
+ hiddenInputValues,
+ inputValue,
+ onReset
+ };
+}
+
+export function useRemoteList({
+ load,
+ defaultItems,
+ defaultSelectedKey,
+ onChange,
+ debounce,
+ allowsCustomValue
+}: {
+ load: Loader;
+ defaultItems?: Item[];
+ defaultSelectedKey?: Key | null;
+ onChange?: (item: Item | null) => void;
+ debounce?: number;
+ allowsCustomValue?: boolean;
+}) {
+ const [defaultSelectedItem, setSelectedItem] = useState
- (() => {
+ if (defaultItems) {
+ return (
+ defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
+ );
+ }
+ return null;
+ });
+ const [inputValue, setInputValue] = useState(
+ defaultSelectedItem?.label ?? ''
+ );
+ const selectedItem = useMemo
- (() => {
+ if (defaultSelectedItem) {
+ return defaultSelectedItem;
+ }
+ if (allowsCustomValue && inputValue != '') {
+ return { label: inputValue, value: inputValue };
+ }
+ return null;
+ }, [defaultSelectedItem, inputValue, allowsCustomValue]);
+ const list = useAsyncList
- ({ getKey, load });
+ const setFilterText = useEvent((filterText: string) => {
+ list.setFilterText(filterText);
+ });
+ const debouncedSetFilterText = useDebounceCallback(
+ setFilterText,
+ debounce ?? 300
+ );
+
+ const onSelectionChange = useEvent<
+ NonNullable
+ >((key) => {
+ const item =
+ typeof key != 'string'
+ ? null
+ : selectedItem?.value == key
+ ? selectedItem
+ : list.getItem(key);
+ setSelectedItem(item);
+ if (item) {
+ setInputValue(item.label);
+ } else if (!allowsCustomValue) {
+ setInputValue('');
+ }
+ onChange?.(item);
+ });
+
+ const onInputChange = useEvent>(
+ (value) => {
+ debouncedSetFilterText(value);
+ setInputValue(value);
+ if (value == '') {
+ onSelectionChange(null);
+ } else if (allowsCustomValue && selectedItem?.label != value) {
+ onChange?.(selectedItem);
+ }
+ }
+ );
+
+ const onReset = useEvent(() => {
+ setSelectedItem(null);
+ setInputValue('');
+ });
+
+ // add to items list current selected item if it's not in the list
+ const items =
+ selectedItem && !list.getItem(selectedItem.value)
+ ? [selectedItem, ...list.items]
+ : list.items;
+
+ return {
+ selectedItem,
+ selectedKey: selectedItem?.value ?? null,
+ onSelectionChange,
+ inputValue,
+ onInputChange,
+ items,
+ onReset
+ };
+}
+
+function getKey(item: Item) {
+ return item.value;
+}
+
+export const createLoader: (
+ source: string,
+ options?: {
+ minimumInputLength?: number;
+ limit?: number;
+ param?: string;
+ }
+) => Loader =
+ (source, options) =>
+ async ({ signal, filterText }) => {
+ const url = new URL(source, location.href);
+ const minimumInputLength = options?.minimumInputLength ?? 2;
+ const param = options?.param ?? 'q';
+ const limit = options?.limit ?? 10;
+
+ if (!filterText || filterText.length < minimumInputLength) {
+ return { items: [] };
+ }
+ url.searchParams.set(param, filterText);
+ try {
+ const response = await fetch(url.toString(), {
+ headers: { accept: 'application/json' },
+ signal
+ });
+ if (response.ok) {
+ const json = await response.json();
+ const [err, result] = s.validate(json, s.array(Item), { coerce: true });
+ if (!err) {
+ const items = matchSorter(result, filterText, {
+ keys: ['label']
+ });
+ return {
+ items: limit ? items.slice(0, limit) : items
+ };
+ }
+ }
+ return { items: [] };
+ } catch {
+ return { items: [] };
+ }
+ };
+
+export function useLabelledBy(id?: string, ariaLabelledby?: string) {
+ return useMemo(
+ () => (ariaLabelledby ? ariaLabelledby : findLabelledbyId(id)),
+ [id, ariaLabelledby]
+ );
+}
+
+function findLabelledbyId(id?: string) {
+ if (!id) {
+ return;
+ }
+ const label = document.querySelector(`[for="${id}"]`);
+ if (!label?.id) {
+ return;
+ }
+ return label.id;
+}
+
+export function useOnFormReset(onReset?: () => void) {
+ const ref = useRef(null);
+ const onResetListener = useEvent((event) => {
+ if (event.target == ref.current?.form) {
+ onReset?.();
+ }
+ });
+ useEffect(() => {
+ if (onReset) {
+ addEventListener('reset', onResetListener);
+ return () => {
+ removeEventListener('reset', onResetListener);
+ };
+ }
+ }, [onReset, onResetListener]);
+
+ return ref;
+}
+
+function distinctBy(array: T[], key: keyof T): T[] {
+ const keys = array.map((item) => item[key]);
+ return array.filter((item, index) => keys.indexOf(item[key]) == index);
+}
diff --git a/app/javascript/components/react-aria/props.ts b/app/javascript/components/react-aria/props.ts
new file mode 100644
index 000000000..4932551c5
--- /dev/null
+++ b/app/javascript/components/react-aria/props.ts
@@ -0,0 +1,82 @@
+import type { ReactNode } from 'react';
+import * as s from 'superstruct';
+
+import type { Loader } from './hooks';
+
+export const Item = s.object({
+ label: s.string(),
+ value: s.string(),
+ data: s.any()
+});
+export type Item = s.Infer;
+
+const ArrayOfTuples = s.coerce(
+ s.array(Item),
+ s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])),
+ (items) =>
+ items.map
- (([label, value]) => ({
+ label,
+ value: String(value)
+ }))
+);
+
+const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) =>
+ items.map
- ((label) => ({ label, value: label }))
+);
+
+const ComboBoxPropsSchema = s.partial(
+ s.object({
+ id: s.string(),
+ className: s.string(),
+ name: s.string(),
+ label: s.string(),
+ description: s.string(),
+ isRequired: s.boolean(),
+ 'aria-label': s.string(),
+ 'aria-labelledby': s.string(),
+ 'aria-describedby': s.string(),
+ items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]),
+ formValue: s.enums(['text', 'key']),
+ form: s.string(),
+ data: s.record(s.string(), s.string())
+ })
+);
+export const SingleComboBoxProps = s.assign(
+ ComboBoxPropsSchema,
+ s.partial(
+ s.object({
+ selectedKey: s.nullable(s.string()),
+ emptyFilterKey: s.nullable(s.string())
+ })
+ )
+);
+export const MultiComboBoxProps = s.assign(
+ ComboBoxPropsSchema,
+ s.partial(
+ s.object({
+ selectedKeys: s.array(s.string()),
+ allowsCustomValue: s.boolean(),
+ valueSeparator: s.string()
+ })
+ )
+);
+export const RemoteComboBoxProps = s.assign(
+ ComboBoxPropsSchema,
+ s.partial(
+ s.object({
+ selectedKey: s.nullable(s.string()),
+ minimumInputLength: s.number(),
+ limit: s.number(),
+ allowsCustomValue: s.boolean()
+ })
+ )
+);
+export type SingleComboBoxProps = s.Infer & {
+ children?: ReactNode;
+};
+export type MultiComboBoxProps = s.Infer;
+export type RemoteComboBoxProps = s.Infer & {
+ children?: ReactNode;
+ loader: Loader | string;
+ onChange?: (item: Item | null) => void;
+};
diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx
index 4b358df3c..964a58d56 100644
--- a/app/javascript/components/shared/FlashMessage.tsx
+++ b/app/javascript/components/shared/FlashMessage.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { createPortal } from 'react-dom';
import invariant from 'tiny-invariant';
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/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx
index b2045b6d0..d160775f8 100644
--- a/app/javascript/components/shared/maplibre/MapLibre.tsx
+++ b/app/javascript/components/shared/maplibre/MapLibre.tsx
@@ -1,4 +1,4 @@
-import React, {
+import {
useState,
useContext,
useRef,
diff --git a/app/javascript/components/shared/maplibre/StyleControl.tsx b/app/javascript/components/shared/maplibre/StyleControl.tsx
index ce83b75c1..afd46345c 100644
--- a/app/javascript/components/shared/maplibre/StyleControl.tsx
+++ b/app/javascript/components/shared/maplibre/StyleControl.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useId } from 'react';
+import { useState, useId } from 'react';
import { Popover, RadioGroup } from '@headlessui/react';
import { usePopper } from 'react-popper';
import { MapIcon } from '@heroicons/react/outline';
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/controllers/lazy/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts
index e80d2e599..58336e926 100644
--- a/app/javascript/controllers/lazy/tiptap_controller.ts
+++ b/app/javascript/controllers/lazy/tiptap_controller.ts
@@ -1,6 +1,6 @@
-import { Editor, type JSONContent } from '@tiptap/core';
+import { Editor } from '@tiptap/core';
import { isButtonElement, isHTMLElement } from '@coldwired/utils';
-import { z } from 'zod';
+import * as s from 'superstruct';
import { ApplicationController } from '../application_controller';
import { getAction } from '../../shared/tiptap/actions';
@@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController {
insertTag(event: MouseEvent) {
if (this.#editor && isHTMLElement(event.target)) {
- const tag = tagSchema.parse(event.target.dataset);
+ const tag = s.create(event.target.dataset, tagSchema);
const editor = this.#editor
.chain()
.focus()
@@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController {
private get content() {
const value = this.inputTarget.value;
if (value) {
- return jsonContentSchema.parse(JSON.parse(value));
+ return s.create(JSON.parse(value), jsonContentSchema);
}
}
private get tags(): TagSchema[] {
- return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset));
+ return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema));
}
private get menuButtons() {
@@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController {
}
}
-const jsonContentSchema: z.ZodType = z.object({
- type: z.string().optional(),
- text: z.string().optional(),
- attrs: z.record(z.any()).optional(),
- marks: z
- .object({ type: z.string(), attrs: z.record(z.any()).optional() })
- .array()
- .optional(),
- content: z.lazy(() => z.array(jsonContentSchema).optional())
+const Attrs = s.record(s.string(), s.any());
+const Marks = s.array(
+ s.type({
+ type: s.string(),
+ attrs: s.optional(Attrs)
+ })
+);
+type JSONContent = {
+ type?: string;
+ text?: string;
+ attrs?: s.Infer;
+ marks?: s.Infer;
+ content?: JSONContent[];
+};
+const jsonContentSchema: s.Describe = s.type({
+ type: s.optional(s.string()),
+ text: s.optional(s.string()),
+ attrs: s.optional(Attrs),
+ marks: s.optional(Marks),
+ content: s.lazy(() => s.optional(s.array(jsonContentSchema)))
});
diff --git a/app/javascript/controllers/menu_button_controller.ts b/app/javascript/controllers/menu_button_controller.ts
index c0b407242..a5edf9b53 100644
--- a/app/javascript/controllers/menu_button_controller.ts
+++ b/app/javascript/controllers/menu_button_controller.ts
@@ -86,6 +86,7 @@ export class MenuButtonController extends ApplicationController {
target.isConnected &&
!this.element.contains(target) &&
!target.closest('reach-portal') &&
+ !target.closest('#rac-portal') &&
this.isOpen
);
}
diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx
deleted file mode 100644
index cdaff89de..000000000
--- a/app/javascript/controllers/react_controller.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { Controller } from '@hotwired/stimulus';
-import React, { lazy, Suspense, FunctionComponent } from 'react';
-import { render, unmountComponentAtNode } from 'react-dom';
-import invariant from 'tiny-invariant';
-
-type Props = Record;
-type Loader = () => Promise<{ default: FunctionComponent }>;
-const componentsRegistry = new Map>();
-const components = import.meta.glob('../components/*.tsx');
-
-for (const [path, loader] of Object.entries(components)) {
- const [filename] = path.split('/').reverse();
- const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
- console.debug(
- `Registered lazy default export for "${componentClassName}" component`
- );
- componentsRegistry.set(
- componentClassName,
- LoadableComponent(loader as Loader)
- );
-}
-
-// Initialize React components when their markup appears into the DOM.
-//
-// Example:
-//
-//
-export class ReactController extends Controller {
- static values = {
- component: String,
- props: Object
- };
-
- declare readonly componentValue: string;
- declare readonly propsValue: Props;
-
- connect(): void {
- this.mountComponent(this.element as HTMLElement);
- }
-
- disconnect(): void {
- unmountComponentAtNode(this.element as HTMLElement);
- }
-
- private mountComponent(node: HTMLElement): void {
- const componentName = this.componentValue;
- const props = this.propsValue;
- const Component = this.getComponent(componentName);
-
- invariant(
- Component,
- `Cannot find a React component with class "${componentName}"`
- );
- render(, node);
- }
-
- private getComponent(componentName: string): FunctionComponent | null {
- return componentsRegistry.get(componentName) ?? null;
- }
-}
-
-const Spinner = () => ;
-
-function LoadableComponent(loader: Loader): FunctionComponent {
- const LazyComponent = lazy(loader);
- const Component: FunctionComponent = (props: Props) => (
- }>
-
-
- );
- return Component;
-}
diff --git a/app/javascript/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts
index 6c8374bc5..58e298294 100644
--- a/app/javascript/controllers/turbo_controller.ts
+++ b/app/javascript/controllers/turbo_controller.ts
@@ -1,7 +1,9 @@
import { Actions } from '@coldwired/actions';
import { parseTurboStream } from '@coldwired/turbo-stream';
+import { createRoot, createReactPlugin, type Root } from '@coldwired/react';
import invariant from 'tiny-invariant';
import { session as TurboSession, type StreamElement } from '@hotwired/turbo';
+import type { ComponentType } from 'react';
import { ApplicationController } from './application_controller';
@@ -20,6 +22,7 @@ export class TurboController extends ApplicationController {
#submitting = false;
#actions?: Actions;
+ #root?: Root;
// `actions` instrface exposes all available actions as methods and also `applyActions` method
// wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also
@@ -32,6 +35,17 @@ export class TurboController extends ApplicationController {
}
connect() {
+ this.#root = createRoot({
+ layoutComponentName: 'Layout/Layout',
+ loader,
+ schema: {
+ fragmentTagName: 'react-fragment',
+ componentTagName: 'react-component',
+ slotTagName: 'react-slot',
+ loadingClassName: 'loading'
+ }
+ });
+ const plugin = createReactPlugin(this.#root);
this.#actions = new Actions({
element: document.body,
schema: {
@@ -40,6 +54,7 @@ export class TurboController extends ApplicationController {
focusDirectionAttribute: 'data-turbo-focus-direction',
hiddenClassName: 'hidden'
},
+ plugins: [plugin],
debug: false
});
@@ -47,6 +62,10 @@ export class TurboController extends ApplicationController {
// They allow us to preserve certain HTML changes across mutations.
this.#actions.observe();
+ this.#actions.ready().then(() => {
+ document.body.classList.add('dom-ready');
+ });
+
// setup spinner events
this.onGlobal('turbo:submit-start', () => this.startSpinner());
this.onGlobal('turbo:submit-end', () => this.stopSpinner());
@@ -73,6 +92,11 @@ export class TurboController extends ApplicationController {
});
}
+ disconnect(): void {
+ this.#actions?.disconnect();
+ this.#root?.destroy();
+ }
+
private startSpinner() {
this.#submitting = true;
this.actions.show({ targets: this.spinnerTargets });
@@ -89,3 +113,24 @@ export class TurboController extends ApplicationController {
}
}
}
+
+type Loader = (exportName: string) => Promise>;
+const componentsRegistry: Record = {};
+const components = import.meta.glob('../components/*.tsx');
+
+const loader: Loader = (name) => {
+ const [moduleName, exportName] = name.split('/');
+ const loader = componentsRegistry[moduleName];
+ invariant(loader, `Cannot find a React component with name "${name}"`);
+ return loader(exportName ?? 'default');
+};
+
+for (const [path, loader] of Object.entries(components)) {
+ const [filename] = path.split('/').reverse();
+ const componentClassName = filename.replace(/\.(ts|tsx)$/, '');
+ console.debug(`Registered lazy export for "${componentClassName}" component`);
+ componentsRegistry[componentClassName] = (exportName) =>
+ loader().then(
+ (m) => (m as Record>)[exportName]
+ );
+}
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