486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
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<Item, string>['load'];
|
|
|
|
export interface ComboBoxProps
|
|
extends Omit<AriaComboBoxProps<Item>, 'children'> {
|
|
children: React.ReactNode | ((item: Item) => React.ReactNode);
|
|
label?: string;
|
|
description?: string;
|
|
}
|
|
|
|
const inputMap = new WeakMap<HTMLInputElement, string>();
|
|
export function useDispatchChangeEvent() {
|
|
const ref = useRef<HTMLSpanElement>(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;
|
|
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 = defaultSelectedKey
|
|
? items.find((item) => item.value == defaultSelectedKey)?.label
|
|
: '';
|
|
setSelectedKey(key);
|
|
setInputValue(inputValue ?? '');
|
|
});
|
|
const onSelectionChange = useEvent<
|
|
NonNullable<ComboBoxProps['onSelectionChange']>
|
|
>((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<NonNullable<ComboBoxProps['onInputChange']>>(
|
|
(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<string, Item>();
|
|
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<ComboBoxProps['onSelectionChange']>
|
|
>((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<NonNullable<ComboBoxProps['onInputChange']>>(
|
|
(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<NonNullable<TagGroupProps['onRemove']>>(
|
|
(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<Item | null>(() => {
|
|
if (defaultItems) {
|
|
return (
|
|
defaultItems.find((item) => item.value == defaultSelectedKey) ?? null
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
const [inputValue, setInputValue] = useState(
|
|
defaultSelectedItem?.label ?? ''
|
|
);
|
|
const selectedItem = useMemo<Item | null>(() => {
|
|
if (defaultSelectedItem) {
|
|
return defaultSelectedItem;
|
|
}
|
|
if (allowsCustomValue && inputValue != '') {
|
|
return { label: inputValue, value: inputValue };
|
|
}
|
|
return null;
|
|
}, [defaultSelectedItem, inputValue, allowsCustomValue]);
|
|
const list = useAsyncList<Item>({ getKey, load });
|
|
const setFilterText = useEvent((filterText: string) => {
|
|
list.setFilterText(filterText);
|
|
});
|
|
const debouncedSetFilterText = useDebounceCallback(
|
|
setFilterText,
|
|
debounce ?? 300
|
|
);
|
|
|
|
const onSelectionChange = useEvent<
|
|
NonNullable<ComboBoxProps['onSelectionChange']>
|
|
>((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<NonNullable<ComboBoxProps['onInputChange']>>(
|
|
(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<HTMLInputElement>(null);
|
|
const onResetListener = useEvent<EventListener>((event) => {
|
|
if (event.target == ref.current?.form) {
|
|
onReset?.();
|
|
}
|
|
});
|
|
useEffect(() => {
|
|
if (onReset) {
|
|
addEventListener('reset', onResetListener);
|
|
return () => {
|
|
removeEventListener('reset', onResetListener);
|
|
};
|
|
}
|
|
}, [onReset, onResetListener]);
|
|
|
|
return ref;
|
|
}
|
|
|
|
function distinctBy<T>(array: T[], key: keyof T): T[] {
|
|
const keys = array.map((item) => item[key]);
|
|
return array.filter((item, index) => keys.indexOf(item[key]) == index);
|
|
}
|