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,22 +32,70 @@ trix-editor.fr-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.fr-ds-combobox {
|
.fr-ds-combobox {
|
||||||
.fr-menu {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.fr-menu__list {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 300px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fr-autocomplete {
|
.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");
|
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) {
|
@media (max-width: 62em) {
|
||||||
.fr-ds-combobox .fr-menu .fr-menu__list {
|
.fr-ds-combobox__menu {
|
||||||
|
&.fr-menu .fr-menu__list {
|
||||||
z-index: calc(var(--ground) + 1000);
|
z-index: calc(var(--ground) + 1000);
|
||||||
background-color: var(--background-default-grey);
|
background-color: var(--background-default-grey);
|
||||||
--idle: transparent;
|
--idle: transparent;
|
||||||
|
@ -57,6 +105,7 @@ trix-editor.fr-input {
|
||||||
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
box-shadow: inset 0 1px 0 0 var(--border-open-blue-france);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fix firefox < 80, Safari < 15.4, Chrome < 83 not supporting "appearance: auto" on inputs
|
// Fix firefox < 80, Safari < 15.4, Chrome < 83 not supporting "appearance: auto" on inputs
|
||||||
// This rule was set by DSFR for DSFR design, but broke our legacy forms.
|
// This rule was set by DSFR for DSFR design, but broke our legacy forms.
|
||||||
|
|
|
@ -634,10 +634,6 @@ textarea::placeholder {
|
||||||
.fr-menu__item {
|
.fr-menu__item {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin-bottom: $default-spacer;
|
margin-bottom: $default-spacer;
|
||||||
|
|
||||||
&[aria-selected] {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -73,22 +73,26 @@ module Dsfr
|
||||||
}
|
}
|
||||||
end
|
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 = @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({
|
.merge({
|
||||||
'fr-password__input': password?,
|
'fr-password__input': password?,
|
||||||
'fr-input': true,
|
'fr-input': !react,
|
||||||
'fr-mb-0': true
|
'fr-mb-0': true
|
||||||
}.merge(input_error_class_names)))
|
}.merge(input_error_class_names)))
|
||||||
if errors_on_attribute?
|
if errors_on_attribute?
|
||||||
@opts.deep_merge!(aria: { describedby: describedby_id })
|
@opts.deep_merge!('aria-describedby': describedby_id)
|
||||||
elsif hintable?
|
elsif hintable?
|
||||||
@opts.deep_merge!(aria: { describedby: hint_id })
|
@opts.deep_merge!('aria-describedby': hint_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
if @required
|
if @required
|
||||||
@opts[:required] = true
|
@opts[react ? :is_required : :required] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
if email?
|
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 &&
|
target.isConnected &&
|
||||||
!this.element.contains(target) &&
|
!this.element.contains(target) &&
|
||||||
!target.closest('reach-portal') &&
|
!target.closest('reach-portal') &&
|
||||||
|
!target.closest('#rac-portal') &&
|
||||||
this.isOpen
|
this.isOpen
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue