feat(js): implement react aria combobox
This commit is contained in:
parent
1e11ad4ce6
commit
df34784d5c
7 changed files with 914 additions and 27 deletions
|
@ -32,29 +32,78 @@ trix-editor.fr-input {
|
|||
}
|
||||
|
||||
.fr-ds-combobox {
|
||||
.fr-menu {
|
||||
width: 100%;
|
||||
|
||||
.fr-menu__list {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.fr-autocomplete {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18.031 16.6168L22.3137 20.8995L20.8995 22.3137L16.6168 18.031C15.0769 19.263 13.124 20 11 20C6.032 20 2 15.968 2 11C2 6.032 6.032 2 11 2C15.968 2 20 6.032 20 11C20 13.124 19.263 15.0769 18.031 16.6168ZM16.0247 15.8748C17.2475 14.6146 18 12.8956 18 11C18 7.1325 14.8675 4 11 4C7.1325 4 4 7.1325 4 11C4 14.8675 7.1325 18 11 18C12.8956 18 14.6146 17.2475 15.8748 16.0247L16.0247 15.8748Z'%3E%3C/path%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
.fr-ds-combobox__menu {
|
||||
&[data-placement=top] {
|
||||
--origin: translateY(8px);
|
||||
}
|
||||
|
||||
&[data-placement=bottom] {
|
||||
--origin: translateY(-8px);
|
||||
}
|
||||
|
||||
&[data-placement=right] {
|
||||
--origin: translateX(-8px);
|
||||
}
|
||||
|
||||
&[data-placement=left] {
|
||||
--origin: translateX(8px);
|
||||
}
|
||||
|
||||
&[data-entering] {
|
||||
animation: popover-slide 200ms;
|
||||
}
|
||||
|
||||
&.fr-menu {
|
||||
width: var(--trigger-width);
|
||||
top: unset;
|
||||
|
||||
.fr-menu__list {
|
||||
display: block;
|
||||
width: unset;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fr-menu__item {
|
||||
&[data-selected] {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&[data-focused] {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popover-slide {
|
||||
from {
|
||||
transform: var(--origin);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 62em) {
|
||||
.fr-ds-combobox .fr-menu .fr-menu__list {
|
||||
z-index: calc(var(--ground) + 1000);
|
||||
background-color: var(--background-default-grey);
|
||||
--idle: transparent;
|
||||
--hover: var(--background-overlap-grey-hover);
|
||||
--active: var(--background-overlap-grey-active);
|
||||
filter: drop-shadow(var(--overlap-shadow));
|
||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||
.fr-ds-combobox__menu {
|
||||
&.fr-menu .fr-menu__list {
|
||||
z-index: calc(var(--ground) + 1000);
|
||||
background-color: var(--background-default-grey);
|
||||
--idle: transparent;
|
||||
--hover: var(--background-overlap-grey-hover);
|
||||
--active: var(--background-overlap-grey-active);
|
||||
filter: drop-shadow(var(--overlap-shadow));
|
||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -634,10 +634,6 @@ textarea::placeholder {
|
|||
.fr-menu__item {
|
||||
list-style-type: none;
|
||||
margin-bottom: $default-spacer;
|
||||
|
||||
&[aria-selected] {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -73,22 +73,26 @@ module Dsfr
|
|||
}
|
||||
end
|
||||
|
||||
def input_opts(other_opts = {})
|
||||
def react_input_opts(other_opts = {})
|
||||
input_opts(other_opts, true)
|
||||
end
|
||||
|
||||
def input_opts(other_opts = {}, react = false)
|
||||
@opts = @opts.deep_merge!(other_opts)
|
||||
@opts[:class] = class_names(map_array_to_hash_with_true(@opts[:class])
|
||||
@opts[react ? :class_name : :class] = class_names(map_array_to_hash_with_true(@opts[:class])
|
||||
.merge({
|
||||
'fr-password__input': password?,
|
||||
'fr-input': true,
|
||||
'fr-input': !react,
|
||||
'fr-mb-0': true
|
||||
}.merge(input_error_class_names)))
|
||||
if errors_on_attribute?
|
||||
@opts.deep_merge!(aria: { describedby: describedby_id })
|
||||
@opts.deep_merge!('aria-describedby': describedby_id)
|
||||
elsif hintable?
|
||||
@opts.deep_merge!(aria: { describedby: hint_id })
|
||||
@opts.deep_merge!('aria-describedby': hint_id)
|
||||
end
|
||||
|
||||
if @required
|
||||
@opts[:required] = true
|
||||
@opts[react ? :is_required : :required] = true
|
||||
end
|
||||
|
||||
if email?
|
||||
|
|
326
app/javascript/components/ComboBox.tsx
Normal file
326
app/javascript/components/ComboBox.tsx
Normal file
|
@ -0,0 +1,326 @@
|
|||
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 {
|
||||
useLabelledBy,
|
||||
useDispatchChangeEvent,
|
||||
useMultiList,
|
||||
useSingleList,
|
||||
useRemoteList,
|
||||
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(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||
|
||||
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||
const { ref, dispatch } = useDispatchChangeEvent();
|
||||
|
||||
const { selectedItem, ...comboBoxProps } = useSingleList({
|
||||
defaultItems,
|
||||
defaultSelectedKey,
|
||||
emptyFilterKey,
|
||||
onChange: dispatch
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComboBox 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' ? 'label' : 'value'}
|
||||
name={name}
|
||||
form={form}
|
||||
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,
|
||||
...props
|
||||
} = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||
|
||||
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||
const { ref, dispatch } = useDispatchChangeEvent();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { selectedItems, hiddenInputValues, onRemove, ...comboBoxProps } =
|
||||
useMultiList({
|
||||
defaultItems,
|
||||
defaultSelectedKeys,
|
||||
onChange: dispatch,
|
||||
formValue,
|
||||
allowsCustomValue,
|
||||
valueSeparator,
|
||||
focusInput: () => {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fr-ds-combobox__multiple">
|
||||
{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"
|
||||
>
|
||||
{item.label}
|
||||
<Button slot="remove" className="fr-tag--dismiss"></Button>
|
||||
</Tag>
|
||||
))}
|
||||
</TagList>
|
||||
</TagGroup>
|
||||
) : null}
|
||||
<ComboBox
|
||||
aria-labelledby={labelledby}
|
||||
allowsCustomValue={allowsCustomValue}
|
||||
inputRef={inputRef}
|
||||
{...comboBoxProps}
|
||||
{...props}
|
||||
>
|
||||
{(item) => <ComboBoxItem id={item.value}>{item.label}</ComboBoxItem>}
|
||||
</ComboBox>
|
||||
{name ? (
|
||||
<span ref={ref}>
|
||||
{hiddenInputValues.map((value) => (
|
||||
<input
|
||||
type="hidden"
|
||||
value={value}
|
||||
name={name}
|
||||
form={form}
|
||||
key={value}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemoteComboBox({
|
||||
loader,
|
||||
onChange,
|
||||
children,
|
||||
...maybeProps
|
||||
}: RemoteComboBoxProps) {
|
||||
const {
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
items: defaultItems,
|
||||
selectedKey: defaultSelectedKey,
|
||||
allowsCustomValue,
|
||||
minimumInputLength,
|
||||
limit,
|
||||
formValue,
|
||||
name,
|
||||
form,
|
||||
data,
|
||||
...props
|
||||
} = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]);
|
||||
|
||||
const labelledby = useLabelledBy(props.id, ariaLabelledby);
|
||||
const { ref, dispatch } = useDispatchChangeEvent();
|
||||
|
||||
const load = useMemo(
|
||||
() =>
|
||||
typeof loader == 'string'
|
||||
? createLoader(loader, { minimumInputLength, limit })
|
||||
: loader,
|
||||
[loader, minimumInputLength, limit]
|
||||
);
|
||||
const { selectedItem, ...comboBoxProps } = useRemoteList({
|
||||
allowsCustomValue,
|
||||
defaultItems,
|
||||
defaultSelectedKey,
|
||||
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}
|
||||
data={data}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</SelectedItemProvider>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComboBoxValueSlot({
|
||||
field,
|
||||
name,
|
||||
form,
|
||||
data
|
||||
}: {
|
||||
field: 'label' | 'value' | 'data';
|
||||
name: string;
|
||||
form?: string;
|
||||
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
|
||||
])
|
||||
);
|
||||
return (
|
||||
<input 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];
|
||||
}
|
438
app/javascript/components/react-aria/hooks.ts
Normal file
438
app/javascript/components/react-aria/hooks.ts
Normal file
|
@ -0,0 +1,438 @@
|
|||
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 { 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 || [], [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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
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 || [], [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?.();
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
onRemove,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
selectedItems,
|
||||
items: filteredItems,
|
||||
hiddenInputValues,
|
||||
inputValue
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
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 result = Item.array().safeParse(json);
|
||||
if (result.success) {
|
||||
const items = matchSorter(result.data, 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;
|
||||
}
|
73
app/javascript/components/react-aria/props.ts
Normal file
73
app/javascript/components/react-aria/props.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Loader } from './hooks';
|
||||
|
||||
export const Item = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
data: z.any().optional()
|
||||
});
|
||||
export type Item = z.infer<typeof Item>;
|
||||
|
||||
const ComboBoxPropsSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
className: z.string(),
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string(),
|
||||
isRequired: z.boolean(),
|
||||
'aria-label': z.string(),
|
||||
'aria-labelledby': z.string(),
|
||||
'aria-describedby': z.string(),
|
||||
items: z
|
||||
.array(Item)
|
||||
.or(
|
||||
z
|
||||
.string()
|
||||
.array()
|
||||
.transform((items) =>
|
||||
items.map<Item>((label) => ({ label, value: label }))
|
||||
)
|
||||
)
|
||||
.or(
|
||||
z
|
||||
.tuple([z.string(), z.string().or(z.number())])
|
||||
.array()
|
||||
.transform((items) =>
|
||||
items.map<Item>(([label, value]) => ({
|
||||
label,
|
||||
value: String(value)
|
||||
}))
|
||||
)
|
||||
),
|
||||
formValue: z.enum(['text', 'key']),
|
||||
form: z.string(),
|
||||
data: z.record(z.string())
|
||||
})
|
||||
.partial();
|
||||
export const SingleComboBoxProps = ComboBoxPropsSchema.extend({
|
||||
selectedKey: z.string().nullable(),
|
||||
emptyFilterKey: z.string()
|
||||
}).partial();
|
||||
export const MultiComboBoxProps = ComboBoxPropsSchema.extend({
|
||||
selectedKeys: z.string().array(),
|
||||
allowsCustomValue: z.boolean(),
|
||||
valueSeparator: z.string()
|
||||
}).partial();
|
||||
export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({
|
||||
selectedKey: z.string().nullable(),
|
||||
minimumInputLength: z.number(),
|
||||
limit: z.number(),
|
||||
allowsCustomValue: z.boolean()
|
||||
}).partial();
|
||||
export type SingleComboBoxProps = z.infer<typeof SingleComboBoxProps> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>;
|
||||
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & {
|
||||
children?: ReactNode;
|
||||
loader: Loader | string;
|
||||
onChange?: (item: Item | null) => void;
|
||||
};
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue