import { useTranslation } from 'react-i18next'; import React, { createElement, isValidElement, KeyboardEvent, PropsWithChildren, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { DropdownContext, DropdownContextType } from './DropdownContext'; import { InputContext, InputContextType } from './InputContext'; import useRootClosePkg from '@restart/ui/useRootClose'; import { FocusContext, FocusContextType } from './FocusContext'; import { ScreenReader } from '../ScreenReader'; import { recursivelyMapChildren } from '../utils/recursivelyMapChildren'; import { DropdownItem, DropdownItemProps, DropdownItemWithIndex } from './DropdownItem'; import { useLayoutEffect } from '../../hooks/useLayoutEffect'; import { useId } from '../../hooks/useId'; const useRootClose = typeof useRootClosePkg === 'function' ? useRootClosePkg : useRootClosePkg['default']; interface DropdownItemData { value: string, itemData?: Record } export interface DropdownProps { screenReaderText: string, screenReaderInstructions?: string, parentQuery?: string, onSelect?: (value: string, index: number, focusedItemData: Record | undefined) => void, onToggle?: ( isActive: boolean, prevValue: string, value: string, index: number, focusedItemData: Record | undefined ) => void, className?: string, activeClassName?: string, alwaysSelectOption?: boolean } /** * Dropdown is the parent component for a set of Dropdown-related components. * * @remarks * It provides multiple shared contexts, which are consumed by its child components, * and also registers some global event listeners. */ export function Dropdown(props: PropsWithChildren): React.JSX.Element { const { t } = useTranslation(); const { children, screenReaderText, screenReaderInstructions, onSelect, onToggle, className, activeClassName, parentQuery, alwaysSelectOption = false } = props; const containerRef = useRef(null); const screenReaderUUID = useId('dropdown'); const dropdownListUUID = useId('dropdown-list'); const [screenReaderKey, setScreenReaderKey] = useState(0); const [hasTyped, setHasTyped] = useState(false); const [childrenWithDropdownItemsTransformed, items] = useMemo(() => { return getTransformedChildrenAndItemData(children); }, [children]); const inputContext = useInputContextInstance(); const { value, setValue, lastTypedOrSubmittedValue, setLastTypedOrSubmittedValue } = inputContext; const focusContext = useFocusContextInstance( items, lastTypedOrSubmittedValue, setValue, setScreenReaderKey, alwaysSelectOption ); const { focusedIndex, focusedItemData, updateFocusedItem } = focusContext; const dropdownContext = useDropdownContextInstance( lastTypedOrSubmittedValue, value, focusedIndex, focusedItemData, screenReaderUUID, dropdownListUUID, setHasTyped, onToggle, onSelect ); const { toggleDropdown, isActive } = dropdownContext; useLayoutEffect(() => { if (parentQuery !== undefined && parentQuery !== lastTypedOrSubmittedValue) { setLastTypedOrSubmittedValue(parentQuery); updateFocusedItem(-1, parentQuery); } }, [ parentQuery, lastTypedOrSubmittedValue, updateFocusedItem, setLastTypedOrSubmittedValue ]); useRootClose(containerRef as React.RefObject, () => { toggleDropdown(false); }, { disabled: !isActive }); function handleKeyDown(e: KeyboardEvent) { if (!isActive) { return; } if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); } if (e.key === 'ArrowDown') { if (alwaysSelectOption && focusedIndex === items.length - 1) { updateFocusedItem(0); } else { updateFocusedItem(focusedIndex + 1); } } else if (e.key === 'ArrowUp') { if (alwaysSelectOption && focusedIndex === 0) { updateFocusedItem(items.length - 1); } else { updateFocusedItem(focusedIndex - 1); } } else if (e.key === 'Tab' && !e.shiftKey) { updateFocusedItem(-1); toggleDropdown(false); } else if (!hasTyped) { setHasTyped(true); } } return (
{childrenWithDropdownItemsTransformed}
); } function useInputContextInstance(): InputContextType { const [value, setValue] = useState(''); const [lastTypedOrSubmittedValue, setLastTypedOrSubmittedValue] = useState(''); return { value, setValue, lastTypedOrSubmittedValue, setLastTypedOrSubmittedValue }; } function useFocusContextInstance( items: DropdownItemData[], lastTypedOrSubmittedValue: string, setValue: (newValue: string) => void, setScreenReaderKey: React.Dispatch>, alwaysSelectOption: boolean ): FocusContextType { const [focusedIndex, setFocusedIndex] = useState(-1); const [focusedValue, setFocusedValue] = useState(null); const [focusedItemData, setFocusedItemData] = useState | undefined>(undefined); useEffect(() => { if (alwaysSelectOption) { if (items.length > 0) { const index = focusedIndex === -1 || focusedIndex >= items.length ? 0 : focusedIndex; setFocusedIndex(index); setFocusedValue(items[index].value); setFocusedItemData(items[index].itemData); } else { setFocusedIndex(-1); setFocusedValue(null); setFocusedItemData(undefined); } } }, [alwaysSelectOption, focusedIndex, items]); function updateFocusedItem(updatedFocusedIndex: number, value?: string) { const numItems = items.length; let updatedValue; if (updatedFocusedIndex === -1 || updatedFocusedIndex >= numItems || numItems === 0) { updatedValue = value ?? lastTypedOrSubmittedValue; if (alwaysSelectOption && numItems !== 0) { setFocusedIndex(0); setFocusedItemData(items[0].itemData); setScreenReaderKey(prev => prev + 1); } else { setFocusedIndex(-1); setFocusedItemData(undefined); setScreenReaderKey(prev => prev + 1); } } else if (updatedFocusedIndex < -1) { const loopedAroundIndex = (numItems + updatedFocusedIndex + 1) % numItems; updatedValue = value ?? items[loopedAroundIndex].value; setFocusedIndex(loopedAroundIndex); setFocusedItemData(items[loopedAroundIndex].itemData); } else { updatedValue = value ?? items[updatedFocusedIndex].value; setFocusedIndex(updatedFocusedIndex); setFocusedItemData(items[updatedFocusedIndex].itemData); } setFocusedValue(updatedValue); setValue(alwaysSelectOption ? (value ?? lastTypedOrSubmittedValue) : updatedValue); } return { focusedIndex, focusedValue, focusedItemData, updateFocusedItem }; } function useDropdownContextInstance( prevValue: string, value: string, index: number, focusedItemData: Record | undefined, screenReaderUUID: string | undefined, dropdownListUUID: string | undefined, setHasTyped: (hasTyped: boolean) => void, onToggle?: ( isActive: boolean, prevValue: string, value: string, index: number, focusedItemData: Record | undefined ) => void, onSelect?: (value: string, index: number, focusedItemData: Record | undefined) => void ): DropdownContextType { const [isActive, _toggleDropdown] = useState(false); const toggleDropdown = (willBeOpen: boolean) => { if (!willBeOpen) { setHasTyped(false); } _toggleDropdown(willBeOpen); onToggle?.(willBeOpen, prevValue, value, index, focusedItemData); }; return { isActive, toggleDropdown, onSelect, screenReaderUUID, dropdownListUUID }; } function getTransformedChildrenAndItemData(children: ReactNode): [ReactNode, DropdownItemData[]] { const items: DropdownItemData [] = []; const childrenWithDropdownItemsTransformed = recursivelyMapChildren(children, (child => { if (!(isValidElement(child) && child.type === DropdownItem)) { return child; } const props = child.props as DropdownItemProps; items.push({ value: props.value, itemData: props.itemData }); return createElement(DropdownItemWithIndex, { ...props, index: items.length - 1 }); })); return [childrenWithDropdownItemsTransformed, items]; }