demarches-normaliennes/app/javascript/components/ComboBox.tsx
2024-09-23 21:49:24 +02:00

367 lines
8.9 KiB
TypeScript

import type { ListBoxItemProps } from 'react-aria-components';
import {
ComboBox as AriaComboBox,
ListBox,
ListBoxItem,
Popover,
Input,
Label,
Text,
Button,
TagGroup,
TagList,
Tag
} from 'react-aria-components';
import { useMemo, useRef, createContext, useContext } from 'react';
import type { RefObject } from 'react';
import { findOrCreateContainerElement } from '@coldwired/react';
import * as s from 'superstruct';
import {
useLabelledBy,
useDispatchChangeEvent,
useMultiList,
useSingleList,
useRemoteList,
useOnFormReset,
createLoader,
type ComboBoxProps
} from './react-aria/hooks';
import {
type Item,
SingleComboBoxProps,
MultiComboBoxProps,
RemoteComboBoxProps
} from './react-aria/props';
const getPortal = () => findOrCreateContainerElement('rac-portal');
export function ComboBox({
children,
label,
description,
className,
inputRef,
...props
}: ComboBoxProps & { inputRef?: RefObject<HTMLInputElement> }) {
return (
<AriaComboBox
{...props}
className={`fr-ds-combobox ${className ?? ''}`}
shouldFocusWrap={true}
>
{label ? <Label className="fr-label">{label}</Label> : null}
{description ? (
<Text slot="description" className="fr-hint-text">
{description}
</Text>
) : null}
<div className="fr-ds-combobox__input" style={{ position: 'relative' }}>
<Input className="fr-select fr-autocomplete" ref={inputRef} />
<Button
style={{
width: '40px',
height: '100%',
position: 'absolute',
opacity: 0,
right: 0,
top: 0
}}
>
{' '}
</Button>
</div>
<Popover
className="fr-ds-combobox__menu fr-menu"
UNSTABLE_portalContainer={getPortal()!}
>
<ListBox className="fr-menu__list">{children}</ListBox>
</Popover>
</AriaComboBox>
);
}
export function ComboBoxItem(props: ListBoxItemProps<Item>) {
return <ListBoxItem {...props} className="fr-menu__item" />;
}
export function SingleComboBox({
children,
...maybeProps
}: SingleComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
emptyFilterKey,
name,
formValue,
form,
data,
...props
} = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const { selectedItem, onReset, ...comboBoxProps } = useSingleList({
defaultItems,
defaultSelectedKey,
emptyFilterKey,
onChange: dispatch
});
return (
<>
<ComboBox
aria-labelledby={labelledby}
menuTrigger="focus"
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={formValue == 'text' ? 'label' : 'value'}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function MultiComboBox(maybeProps: MultiComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKeys: defaultSelectedKeys,
name,
form,
formValue,
allowsCustomValue,
valueSeparator,
className,
...props
} = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const inputRef = useRef<HTMLInputElement>(null);
const {
selectedItems,
hiddenInputValues,
onRemove,
onReset,
...comboBoxProps
} = useMultiList({
defaultItems,
defaultSelectedKeys,
onChange: dispatch,
formValue,
allowsCustomValue,
valueSeparator,
focusInput: () => {
inputRef.current?.focus();
}
});
const formResetRef = useOnFormReset(onReset);
return (
<div className={`fr-ds-combobox__multiple ${className ? className : ''}`}>
{selectedItems.length > 0 ? (
<TagGroup onRemove={onRemove} aria-label={props['aria-label']}>
<TagList items={selectedItems} className="fr-tag-list">
{selectedItems.map((item) => (
<Tag
key={item.value}
id={item.value}
textValue={`Retirer ${item.label}`}
className="fr-tag fr-tag--sm fr-tag--dismiss"
>
{item.label}
<Button slot="remove" className="fr-tag--dismiss"></Button>
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<ComboBox
aria-labelledby={labelledby}
allowsCustomValue={allowsCustomValue}
inputRef={inputRef}
menuTrigger="focus"
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{name ? (
<span ref={ref}>
{hiddenInputValues.length == 0 ? (
<input
type="hidden"
value=""
name={name}
form={form}
ref={formResetRef}
/>
) : (
hiddenInputValues.map((value, i) => (
<input
type="hidden"
value={value}
name={name}
form={form}
ref={i == 0 ? formResetRef : undefined}
key={value}
/>
))
)}
</span>
) : null}
</div>
);
}
export function RemoteComboBox({
loader,
onChange,
children,
...maybeProps
}: RemoteComboBoxProps) {
const {
'aria-labelledby': ariaLabelledby,
items: defaultItems,
selectedKey: defaultSelectedKey,
allowsCustomValue,
minimumInputLength,
limit,
debounce,
coerce,
formValue,
name,
form,
data,
...props
} = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent();
const load = useMemo(
() =>
typeof loader == 'string'
? createLoader(loader, { minimumInputLength, limit, coerce })
: loader,
[loader, minimumInputLength, limit, coerce]
);
const { selectedItem, onReset, ...comboBoxProps } = useRemoteList({
allowsCustomValue,
defaultItems,
defaultSelectedKey,
debounce,
load,
onChange: (item) => {
onChange?.(item);
dispatch();
}
});
return (
<>
<ComboBox
allowsEmptyCollection={comboBoxProps.inputValue.length > 0}
allowsCustomValue={allowsCustomValue}
aria-labelledby={labelledby}
{...comboBoxProps}
{...props}
>
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
</ComboBox>
{children || name ? (
<span ref={ref}>
<SelectedItemProvider value={selectedItem}>
{name ? (
<ComboBoxValueSlot
field={
formValue == 'text' || allowsCustomValue ? 'label' : 'value'
}
name={name}
form={form}
onReset={onReset}
data={data}
/>
) : null}
{children}
</SelectedItemProvider>
</span>
) : null}
</>
);
}
export function ComboBoxValueSlot({
field,
name,
form,
onReset,
data
}: {
field: 'label' | 'value' | 'data';
name: string;
form?: string;
onReset?: () => void;
data?: Record<string, string>;
}) {
const selectedItem = useContext(SelectedItemContext);
const value = getSelectedValue(selectedItem, field);
const dataProps = Object.fromEntries(
Object.entries(data ?? {}).map(([key, value]) => [
`data-${key.replace(/_/g, '-')}`,
value
])
);
const ref = useOnFormReset(onReset);
return (
<input
ref={onReset ? ref : undefined}
type="hidden"
name={name}
value={value}
form={form}
{...dataProps}
/>
);
}
const SelectedItemContext = createContext<Item | null>(null);
const SelectedItemProvider = SelectedItemContext.Provider;
function getSelectedValue(
selectedItem: Item | null,
field: 'label' | 'value' | 'data'
): string {
if (selectedItem == null) {
return '';
} else if (field == 'data') {
if (typeof selectedItem.data == 'string') {
return selectedItem.data;
} else if (!selectedItem.data) {
return '';
}
return JSON.stringify(selectedItem.data);
}
return selectedItem[field];
}