import { useCallback, useRef, useState } from 'react'; import type * as React from 'react'; import DropdownMenu from './DropdownMenu'; import debounce from '../utilities/debounce'; import describeField from '../utilities/describeField'; import classNames from 'classnames'; import cleanFieldProps from '../utilities/cleanFieldProps'; import mergeRefs from '../utilities/mergeRefs'; import useClickOutsideHandler from '../utilities/useClickOutsideHandler'; import useId from '../utilities/useId'; import useAutofocus from '../utilities/useAutoFocus'; import { SvgIcon } from '../Icons'; import { getFirstOptionValue, isOptGroup, parseChildren, validateProps } from './utils'; import { Item, Section, useSelectState } from '../react-aria'; // from react-stately import { HiddenSelect, useButton, useSelect } from '../react-aria'; // from react-aria import { useLabelProps, UseLabelPropsProps } from '../Label/useLabelProps'; import { useHint, UseHintProps } from '../Hint/useHint'; import { useInlineError, UseInlineErrorProps } from '../InlineError/useInlineError'; const caretIcon = ( ); export type DropdownSize = 'small' | 'medium'; export type DropdownValue = number | string; export interface DropdownChangeObject { target: { value: string; name: string }; currentTarget: { value: string; name: string }; } export interface DropdownOption extends React.HTMLAttributes<'option'> { label: React.ReactNode; value: DropdownValue; } export interface DropdownOptGroup extends React.HTMLAttributes<'optgroup'> { label: React.ReactNode; options: DropdownOption[]; } export interface BaseDropdownProps { /** * Sets the focus on the dropdown when it is first added to the document. */ autoFocus?: boolean; /** * Sets the initial selected state. Use this for an uncontrolled component; * otherwise, use the `value` property. */ defaultValue?: DropdownValue; /** * Disables the entire field. */ disabled?: boolean; /** * Additional classes to be added to the root element. */ className?: string; /** * Additional classes to be added to the dropdown button element */ fieldClassName?: string; /** * A unique ID to be used for the `button` element. If one isn't provided, a unique ID will be generated. * Additional hint text to display */ id?: string; /** * Access a reference to the `button` element */ inputRef?: React.Ref | React.MutableRefObject; /** * Set to `true` to apply the "inverse" color scheme */ inversed?: boolean; /** * The field's `name` attribute */ name: string; onBlur?: (...args: any[]) => any; onChange?: (change: DropdownChangeObject) => any; /** * Text showing the requirement ("Required", "Optional", etc.). See [Required and Optional Fields](https://design.cms.gov/patterns/Forms/forms/#required-and-optional-fields). */ requirementLabel?: React.ReactNode; /** * Sets the max-width of the input either to `'small'` or `'medium'`. */ size?: DropdownSize; /** * Sets the field's `value`. Use this in combination with `onChange` * for a controlled component; otherwise, set `defaultValue`. */ value?: DropdownValue; /** * Customize the default status messages announced to screen reader users via * aria-live during certain interactions. * @deprecated This option is not currently supported. * @hide-prop [Deprecated] */ getA11yStatusMessage?: any; /** * Customize the default status messages announced to screen reader users via * aria-live when a selection is made. * @deprecated This option is not currently supported. * @hide-prop [Deprecated] */ getA11ySelectionMessage?: any; } type OptionsOrChildren = | { children?: undefined; /** * The list of options to be rendered. Each item must have a `label` and `value`. */ options: Array; } | { /** * Used to define custom dropdown options (i.e. option groups). Alternative to `options` prop. */ children: React.ReactNode; options?: undefined; }; export type DropdownProps = BaseDropdownProps & OptionsOrChildren & Omit, keyof BaseDropdownProps> & Omit; /** * For information about how and when to use this component, * [refer to its full documentation page](https://design.cms.gov/components/dropdown/). */ export const Dropdown: React.FC = (props: DropdownProps) => { validateProps(props); const id = useId('dropdown--', props.id); const buttonContentId = `${id}__button-content`; const menuId = `${id}__menu`; // Draw out certain props that we don't want to pass through as attributes const { autoFocus, children, className, fieldClassName, onBlur: userOnBlur, onChange, options, size, defaultValue, value, inputRef, inversed, getA11yStatusMessage, getA11ySelectionMessage, ...extraProps } = props; const optionsAndGroups = options ?? parseChildren(children); const renderReactStatelyItem = (item: DropdownOption) => { const { label, value, ...extraAttrs } = item; return ( {label} ); }; const reactStatelyItems = optionsAndGroups.map((item, index) => { if (isOptGroup(item)) { const { label, options, ...extraAttrs } = item; return (
{options.map(renderReactStatelyItem)}
); } else { return renderReactStatelyItem(item); } }); const isControlled = value !== undefined; let fallbackValue = defaultValue; if (!isControlled && fallbackValue === undefined) { fallbackValue = getFirstOptionValue(optionsAndGroups); } const [internalValueState, setInternalValueState] = useState(fallbackValue); const selectedKey = isControlled ? value : internalValueState; const onSelectionChange = (value: string) => { triggerRef.current?.focus?.(); if (onChange) { // Try to support the old API that passed an event object const target = { value, name: props.name }; onChange({ target, currentTarget: target, }); } if (!isControlled) { setInternalValueState(value); } }; const state = useSelectState({ ...props, children: reactStatelyItems, selectedKey, onSelectionChange, }); const { errorId, topError, bottomError, invalid } = useInlineError({ ...props, id }); const { hintId, hintElement } = useHint({ ...props, id }); const onBlur = useCallback( // The active element is always the document body during a focus transition, // so in order to check if the newly focused element is one of our other date // inputs, we're going to have to wait a bit. We also have an issue with // tabbing out firing two blur events, so debounce during that time too. In // order for the debounce to work, we need to wrap this in a useCallback so // don't create a new one on each render. debounce((event: React.FocusEvent) => { // Only call the user's onBlur handler if focus leaves the whole component if (!wrapperRef.current?.contains(document.activeElement)) { userOnBlur?.(event); state.setOpen(false); } }, 20), [userOnBlur, state] ); const triggerRef = useRef(); const useSelectProps = useSelect( { ...props, onBlur, isDisabled: props.disabled }, state, triggerRef ); const useButtonProps = useButton(useSelectProps.triggerProps, triggerRef); const labelProps = { ...useSelectProps.labelProps, ...useLabelProps({ ...props, id, labelClassName: classNames( 'ds-c-label', 'ds-c-dropdown__label', props.inversed && 'ds-c-label--inverse', props.labelClassName ), }), }; // Excluding `inversed` prop from `
` label because it's not a valid attr const { inversed: _removeInversed, ...divLabelProps } = labelProps; const buttonProps = { ...useButtonProps.buttonProps, ...cleanFieldProps(extraProps), id, name: undefined, className: classNames( 'ds-c-dropdown__button', 'ds-c-field', props.errorMessage && 'ds-c-field--error', inversed && 'ds-c-field--inverse', size && `ds-c-field--${size}`, fieldClassName ), ref: mergeRefs([triggerRef, inputRef, useAutofocus(props.autoFocus)]), 'aria-controls': menuId, 'aria-labelledby': `${buttonContentId} ${labelProps.id}`, 'aria-describedby': describeField({ ...props, hintId, errorId }), // TODO: Someday we may want to add this `combobox` role back to the button, but right // now desktop VoiceOver has an issue. It seems to interpret the selected value in the // button as user input that needs to be checked for spelling (default setting). It // therefore announces anything it deems misspelled as such. The `react-aria` authors // likely ran into the same issue, since they leave it off for `useSelect` buttons. // Adding the combobox role in the future can help because screen reader users are more // familiar with the combobox pattern. // Another possible issue with this role - you should be able to select an option by typing // a character from that option. Without this role set, VO reads whatever option is closest // to the character typed. With this role set, VO reads nothing. // role: 'combobox', }; const wrapperRef = useRef(); useClickOutsideHandler([wrapperRef], () => state.setOpen(false)); return (
{/* `
` is used instead of `
); }; export default Dropdown;