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 (