diff --git a/app/assets/stylesheets/dsfr.scss b/app/assets/stylesheets/dsfr.scss index 2b312fc36..4de7cf6e9 100644 --- a/app/assets/stylesheets/dsfr.scss +++ b/app/assets/stylesheets/dsfr.scss @@ -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); + } } } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 3b144f941..c743405fd 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -634,10 +634,6 @@ textarea::placeholder { .fr-menu__item { list-style-type: none; margin-bottom: $default-spacer; - - &[aria-selected] { - font-weight: bold; - } } } diff --git a/app/components/dsfr/input_errorable.rb b/app/components/dsfr/input_errorable.rb index efe7de775..9ae359fd8 100644 --- a/app/components/dsfr/input_errorable.rb +++ b/app/components/dsfr/input_errorable.rb @@ -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? diff --git a/app/javascript/components/ComboBox.tsx b/app/javascript/components/ComboBox.tsx new file mode 100644 index 000000000..63abf1a88 --- /dev/null +++ b/app/javascript/components/ComboBox.tsx @@ -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 }) { + return ( + + {label ? : null} + {description ? ( + + {description} + + ) : null} +
+ + +
+ + {children} + +
+ ); +} + +export function ComboBoxItem(props: ListBoxItemProps) { + return ; +} + +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 ( + <> + + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : 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(null); + + const { selectedItems, hiddenInputValues, onRemove, ...comboBoxProps } = + useMultiList({ + defaultItems, + defaultSelectedKeys, + onChange: dispatch, + formValue, + allowsCustomValue, + valueSeparator, + focusInput: () => { + inputRef.current?.focus(); + } + }); + + return ( +
+ {selectedItems.length > 0 ? ( + + + {selectedItems.map((item) => ( + + {item.label} + + + ))} + + + ) : null} + + {(item) => {item.label}} + + {name ? ( + + {hiddenInputValues.map((value) => ( + + ))} + + ) : null} +
+ ); +} + +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 ( + <> + 0} + allowsCustomValue={allowsCustomValue} + aria-labelledby={labelledby} + {...comboBoxProps} + {...props} + > + {(item) => {item.label}} + + {children || name ? ( + + + {name ? ( + + ) : null} + {children} + + + ) : null} + + ); +} + +export function ComboBoxValueSlot({ + field, + name, + form, + data +}: { + field: 'label' | 'value' | 'data'; + name: string; + form?: string; + data?: Record; +}) { + 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 ( + + ); +} + +const SelectedItemContext = createContext(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]; +} diff --git a/app/javascript/components/react-aria/hooks.ts b/app/javascript/components/react-aria/hooks.ts new file mode 100644 index 000000000..7974e304e --- /dev/null +++ b/app/javascript/components/react-aria/hooks.ts @@ -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['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; + 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 + >((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); + } + } + ); + + // 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(); + 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?.(); + } + ); + + 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(() => { + 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); + } + } + ); + + // 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; +} diff --git a/app/javascript/components/react-aria/props.ts b/app/javascript/components/react-aria/props.ts new file mode 100644 index 000000000..e67ac1096 --- /dev/null +++ b/app/javascript/components/react-aria/props.ts @@ -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; + +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((label) => ({ label, value: label })) + ) + ) + .or( + z + .tuple([z.string(), z.string().or(z.number())]) + .array() + .transform((items) => + items.map(([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 & { + children?: ReactNode; +}; +export type MultiComboBoxProps = z.infer; +export type RemoteComboBoxProps = z.infer & { + children?: ReactNode; + loader: Loader | string; + onChange?: (item: Item | null) => void; +}; 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 ); }