/* Copyright 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import ChevronDown from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; import Check from "@vector-im/compound-design-tokens/assets/web/icons/check"; import Error from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import React, { type Dispatch, forwardRef, type HTMLProps, memo, type RefObject, type SetStateAction, useCallback, useEffect, useRef, useState, type KeyboardEvent, useMemo, } from "react"; import classNames from "classnames"; import styles from "./Dropdown.module.css"; import { useId } from "@floating-ui/react"; type DropdownProps = { /** * The CSS class name. */ className?: string; /** * The controlled value of the dropdown. */ value?: string; /** * The default value of the dropdown, used when uncontrolled. */ defaultValue?: string; /** * The values of the dropdown. * [value, text] */ values: [string, string][]; /** * The placeholder text. */ placeholder: string; /** * The label to display at the top of the dropdown */ label: string; /** * The help label to display at the bottom of the dropdown */ helpLabel?: string; /** * Callback for when the value changes. * @param value */ onValueChange?: (value: string) => void; /** * The error message to display. */ error?: string; }; /** * The dropdown content. */ export const Dropdown = forwardRef( function Dropdown( { className, label, placeholder, helpLabel, onValueChange, error, value: controlledValue, defaultValue, values, ...props }, ref, ) { const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); const value = controlledValue ?? uncontrolledValue; const text = useMemo( () => value === undefined ? placeholder : (values.find(([v]) => v === value)?.[1] ?? placeholder), [value, values, placeholder], ); const setValue = useCallback( (value: string) => { setUncontrolledValue(value); onValueChange?.(value); }, [setUncontrolledValue, onValueChange], ); const [open, setOpen, dropdownRef] = useOpen(); const { listRef, onComboboxKeyDown, onOptionKeyDown } = useKeyboardShortcut( open, setOpen, setValue, ); const buttonRef = useRef(null); useEffect(() => { // Focus the button when the value is set // Test if the value is undefined to avoid focusing on the first render if (value !== undefined) buttonRef.current?.focus(); }, [value]); const hasPlaceholder = text === placeholder; const buttonClasses = classNames({ [styles.placeholder]: hasPlaceholder, }); const borderClasses = classNames(styles.border, { [styles.open]: open, }); const contentClasses = classNames(styles.content, { [styles.open]: open, }); /** * Ids for accessibility. */ const labelId = useId(); const contentId = useId(); return (
    {values.map(([v, text]) => ( { setOpen(false); setValue(v); }} onKeyDown={(e) => onOptionKeyDown(e, v)} > {text} ))}
{!error && helpLabel && ( {helpLabel} )} {error && ( {error} )}
); }, ); type DropdownItemProps = HTMLProps & { /** * Whether the dropdown item is selected. */ isSelected: boolean; /** * Whether the dropdown item is displayed. */ isDisplayed: boolean; /** * The text to display in the dropdown item. */ children: string; }; /** * A dropdown item component. */ const DropdownItem = memo(function DropdownItem({ children, isSelected, isDisplayed, ...props }: DropdownItemProps) { const ref = useRef(null); // Focus the item if the dropdown is open and the item is already selected useEffect(() => { if (isSelected && isDisplayed) { ref.current?.focus(); } }, [isSelected, isDisplayed]); return (
  • {children} {isSelected && }
  • ); }); /** * A hook to manage the open state of the dropdown. */ function useOpen(): [ boolean, Dispatch>, RefObject, ] { const [open, setOpen] = useState(false); const ref = useRef(null); // If the user clicks outside the dropdown, close it useEffect(() => { const closeIfOutside = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener("click", closeIfOutside); return () => document.removeEventListener("click", closeIfOutside); }, [setOpen]); return [open, setOpen, ref]; } /** * A hook to manage the keyboard shortcuts of the dropdown. * @param open - the dropdown open state. * @param setOpen - the dropdown open state setter. * @param setValue - set the selected value and text */ function useKeyboardShortcut( open: boolean, setOpen: Dispatch>, setValue: (value: string) => void, ) { const listRef = useRef(null); const onComboboxKeyDown = useCallback( ({ key }: KeyboardEvent) => { switch (key) { // Enter and Space already managed because it's a button case "Escape": setOpen(false); break; case "ArrowDown": setOpen(true); // If open, focus the first element if (open) { (listRef.current?.firstElementChild as HTMLElement)?.focus(); } break; case "ArrowUp": setOpen(true); break; case "Home": { setOpen(true); // Wait for the dropdown to be opened Promise.resolve().then(() => { (listRef.current?.firstElementChild as HTMLElement)?.focus(); }); break; } case "End": { setOpen(true); // Wait for the dropdown to be opened Promise.resolve().then(() => { (listRef.current?.lastElementChild as HTMLElement)?.focus(); }); break; } } }, [listRef, open, setOpen], ); const onOptionKeyDown = useCallback( (evt: KeyboardEvent, value: string) => { const { key, altKey } = evt; evt.stopPropagation(); evt.preventDefault(); switch (key) { case "Enter": case " ": { setValue(value); setOpen(false); break; } case "Tab": case "Escape": setOpen(false); break; case "ArrowDown": { const currentFocus = document.activeElement; if (listRef.current?.contains(currentFocus) && currentFocus) { (currentFocus.nextElementSibling as HTMLElement)?.focus(); } break; } case "ArrowUp": { if (altKey) { setValue(value); setOpen(false); } else { const currentFocus = document.activeElement; if (listRef.current?.contains(currentFocus) && currentFocus) { (currentFocus.previousElementSibling as HTMLElement)?.focus(); } } break; } case "Home": { (listRef.current?.firstElementChild as HTMLElement)?.focus(); break; } case "End": { (listRef.current?.lastElementChild as HTMLElement)?.focus(); break; } } }, [listRef, setValue, setOpen], ); return { listRef, onComboboxKeyDown, onOptionKeyDown }; }