"use client"; import { cnb } from "cnbuilder"; import { type ChangeEvent, type HTMLAttributes, type InputHTMLAttributes, type ReactElement, type ReactNode, type Ref, useCallback, useRef, useState, } from "react"; import { type BoxProps } from "../box/Box.js"; import { IconRotator } from "../icon/IconRotator.js"; import { getIcon } from "../icon/config.js"; import { type MenuProps } from "../menu/Menu.js"; import { KeyboardMovementProvider } from "../movement/useKeyboardMovementProvider.js"; import { type LabelA11y, type PropsWithRef, type RequireAtLeastOne, } from "../types.js"; import { useEnsuredId } from "../useEnsuredId.js"; import { useEnsuredRef } from "../useEnsuredRef.js"; import { Listbox } from "./Listbox.js"; import { type OptionProps } from "./Option.js"; import { SelectedOption } from "./SelectedOption.js"; import { TextFieldContainer, type TextFieldContainerProps, } from "./TextFieldContainer.js"; import { getFormConfig } from "./formConfig.js"; import { label as labelStyles } from "./labelStyles.js"; import { select } from "./selectStyles.js"; import { extractOptionsFromChildren } from "./selectUtils.js"; import { textField } from "./textFieldStyles.js"; import { type UserAgentAutocompleteProps } from "./types.js"; import { useFormReset } from "./useFormReset.js"; import { useSelectCombobox } from "./useSelectCombobox.js"; import { triggerManualChangeEvent } from "./utils.js"; const EMPTY_STRING = "" as const; const noop = (): void => { // do nothing }; /** * @since 6.5.0 */ export interface GetSelectedOptionChildrenOptions< Value extends string = string, > { value: "" | Value; /** * The option will be undefined if there is no value or matching option. */ option: OptionProps | undefined; /** * This is a pass-through of the {@link SelectProps.placeholder} */ placeholder?: ReactNode; /** * This is a pass-through of the {@link SelectProps.selectedOptionProps} */ children?: ReactNode; } /** * @since 6.5.0 */ const defaultGetSelectedOptionChildren = ( options: GetSelectedOptionChildrenOptions ): ReactNode => { const { children, option, placeholder } = options; return children ?? (option?.children || placeholder); }; /** * This is a convenience type for casting the `event.currentTarget.value` of a * `Select`'s change event to be union of available values. * * Note: The change event does not provide any sort of validation on the value * so automation tools like Cypress, Playwright, or Selenium might set an * invalid value. This also does not work for numbers, so you will need to * implement that yourself. * * @example Simple Usage * ```tsx * import { Select, type SelectChangeEvent } from "@react-md/core/form/Select"; * import { Option } from "@react-md/core/form/Option"; * import type { ReactElement } from "react"; * * type Value = "" | "a" | "b" | "c" | "d"; * * function Example(): ReactElement { * const [value, setValue] = useState(""); * * const handleChange = (event: SelectChangeEvent): void => { * // No type error! * // `event.currentTarget.value`'s type is `Value` instead of a generic `string` * setValue(event.currentTarget.value); * }; * * return ( * * ); * } * ``` * @since 6.0.0 */ export type SelectChangeEvent = ChangeEvent & { currentTarget: { value: Value } }; /** * @since 6.0.0 Rewritten with a new API. */ export interface SelectProps extends Omit, Pick, "form" | "required">, UserAgentAutocompleteProps { /** * @defaultValue `"select-" + useId()` */ id?: string; /** * Optional placeholder text or element to render while no options have been * selected. */ placeholder?: ReactNode; /** * Set this to a custom dropdown icon or `null` to not render a dropdown icon. * * @defaultValue `getIcon("dropdown")` */ icon?: ReactNode; /** * Set this value to fully control the value of the select component. The * {@link onChange} handler **must** also be provided if this prop exists. */ value?: Value; /** * An optional default value when the value of the select component is * uncontrolled. * * @defaultValue `""` */ defaultValue?: Value; /** @see {@link SelectChangeEvent} */ onChange?: (event: SelectChangeEvent) => void; /** * An optional floating label to display like other form fields. */ label?: ReactNode; /** * Optional props to pass to the `` that surrounds the {@link label} */ labelProps?: PropsWithRef>; /** * An optional ref to pass to the hidden `` element that * stores the current value. This is really only useful if you'd like to keep * this component uncontrolled and access the value through * `inputRef.current.value`. */ inputRef?: Ref; inputProps?: InputHTMLAttributes; /** * A ref for the container `
` element. */ containerRef?: Ref; /** * Any additional props to provide to the `Menu` component that renders all * the `Option`s. * * The menu will always have these default values unless explicity * overwritten by this prop: * * - `aria-labelledby={label ? labelId : id}` -- this will be undefined if * `aria-label` is provided * - `anchor={BELOW_CENTER_ANCHOR}` * - `width="min"` */ menuProps?: PropsWithRef< Omit >; /** * Any additional props to pass to the div that contains the current visible * option. */ selectedOptionProps?: BoxProps; /** * Set this to `true` if all the `Option` components should display the * selected icon after the children instead of before. * * @see {@link disableSelectedIcon} to remove the selected icon instead. * * @defaultValue `false` */ selectedIconAfter?: boolean; /** * Set this to `true` to prevent the current option from rendering the * `leftAddon` in the `TextFieldContainer`. * * @defaultValue `false` */ disableOptionAddon?: boolean; /** * Set this to `true` to update all the `Option` components to no longer * render an icon while selected. * * @defaultValue `false` */ disableSelectedIcon?: boolean; /** * @since 6.5.0 * @defaultValue `({ children, option, placeholder }) => children ?? (option?.children || placeholder)` */ getSelectedOptionChildren?: ( options: GetSelectedOptionChildrenOptions ) => ReactNode; /** * This should be the available `Option`s for the select to choose from. It * can also contain `OptGroup` or any other elements but only clicking on an * `Option` component will update the value. */ children: ReactNode; } /** * **Client Component** * * @example Simple Example * ```tsx * import { Option } from "@react-md/core/form/Option"; * import { Select } from "@react-md/core/form/Select"; * import { useState, type ReactElement } from "react"; * * function Example(): ReactElement { * const [value, setValue] = useState(""); * * return ( * * ); * } * ``` * * @see {@link https://react-md.dev/components/select | Select Demos} * @since 6.0.0 Rewritten with a new API. */ export function Select( props: RequireAtLeastOne, "label" | keyof LabelA11y> ): ReactElement { const { id, form, autoCompleteValue, autoComplete = autoCompleteValue, name = autoCompleteValue, className, onClick, onFocus, onKeyDown, inputRef: propInputRef, inputProps, containerRef, placeholder, menuProps = {}, label, labelProps = {}, selectedOptionProps, getSelectedOptionChildren = defaultGetSelectedOptionChildren, icon: propIcon, value, defaultValue, theme: propTheme, onChange = noop, rightAddon: propRightAddon, active = false, required, selectedIconAfter = false, disableOptionAddon = false, disableSelectedIcon = false, children, ...remaining } = props; const { dense, error, disabled } = props; const comboboxId = useEnsuredId(id, "select"); const inputId = useEnsuredId(inputProps?.id, "select-value"); const selectLabelId = useEnsuredId(labelProps.id, "select-label"); const labelId = label ? selectLabelId : undefined; const [localValue, setLocalValue] = useState(() => { if (defaultValue !== undefined) { return defaultValue; } return value !== undefined ? value : EMPTY_STRING; }); const currentValue = value === undefined ? localValue : value; const initialValue = useRef(currentValue); const { options, currentOption } = extractOptionsFromChildren( children, currentValue ); const { visible, comboboxProps, movementContext, getMenuProps } = useSelectCombobox({ form, value: currentValue, values: options, onClick, onFocus, onKeyDown, disabled, popupId: menuProps.id, popupRef: menuProps.ref, comboboxId, comboboxRef: containerRef, }); const [inputRef, inputRefCallback] = useEnsuredRef(propInputRef); useFormReset({ form, elementRef: inputRef, defaultValue: initialValue.current, }); const icon = getIcon("dropdown", propIcon); const theme = getFormConfig("theme", propTheme); let rightAddon = propRightAddon; if (rightAddon === undefined && icon) { rightAddon = {icon}; } const { ref: listboxRef, ...listboxProps } = getMenuProps(menuProps); let listboxLabelledBy = menuProps["aria-labelledby"]; const listboxLabel = menuProps["aria-label"]; if (!listboxLabel && !listboxLabelledBy) { listboxLabelledBy = labelId || comboboxId; } return ( {getSelectedOptionChildren({ value: currentValue, option: currentOption, placeholder, children: selectedOptionProps?.children, })} { onChange(event as SelectChangeEvent); if (value !== undefined) { return; } const nextValue = event.currentTarget.value; const nextOption = options.find((option) => option === nextValue); setLocalValue(nextOption ?? initialValue.current); }} /> {label && ( {label} )} { triggerManualChangeEvent(inputRef.current, option); }, [inputRef] )} selectedIconAfter={selectedIconAfter} disableSelectedIcon={disableSelectedIcon} > {children} ); }