diff --git a/docs/data/base/components/select/SelectIntroduction/system/index.tsx b/docs/data/base/components/select/SelectIntroduction/system/index.tsx index 2b3ba86e61..d95d979e5d 100644 --- a/docs/data/base/components/select/SelectIntroduction/system/index.tsx +++ b/docs/data/base/components/select/SelectIntroduction/system/index.tsx @@ -5,13 +5,13 @@ import Check from '@mui/icons-material/Check'; export default function UnstyledSelectIntroduction() { return ( - + Trigger {[...Array(100)].map((_, index) => ( - + Item {index + 1} } /> diff --git a/packages/mui-base/src/Select/Item/SelectItem.tsx b/packages/mui-base/src/Select/Item/SelectItem.tsx index e6ee9aad1f..c394251f8f 100644 --- a/packages/mui-base/src/Select/Item/SelectItem.tsx +++ b/packages/mui-base/src/Select/Item/SelectItem.tsx @@ -11,6 +11,7 @@ import { useForkRef } from '../../utils/useForkRef'; import { useEventCallback } from '../../utils/useEventCallback'; import { SelectItemContext } from './SelectItemContext'; import { commonStyleHooks } from '../utils/commonStyleHooks'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; const InnerSelectItem = React.memo( React.forwardRef(function InnerSelectItem( @@ -74,44 +75,59 @@ const InnerSelectItem = React.memo( ); /** - * An unstyled menu item to be used within a Menu. + * An unstyled select item to be used within a Select. * * Demos: * - * - [Menu](https://mui.com/base-ui/react-menu/) + * - [Select](https://mui.com/base-ui/react-select/) * * API: * - * - [SelectItem API](https://mui.com/base-ui/react-menu/components-api/#menu-item) + * - [SelectItem API](https://mui.com/base-ui/react-select/components-api/#select-item) */ const SelectItem = React.forwardRef(function SelectItem( props: SelectItem.Props, forwardedRef: React.ForwardedRef, ) { - const { id: idProp, label, ...otherProps } = props; + const { id: idProp, value: valueProp, label, ...otherProps } = props; const { + setValue, open, getItemProps, activeIndex, selectedIndex, setOpen, typingRef, - setSelectedIndex, selectionRef, + valuesRef, } = useSelectRootContext(); const [item, setItem] = React.useState(null); - const listItem = useListItem({ label: label ?? item?.textContent }); + const itemLabel = label ?? item?.textContent ?? null; + const listItem = useListItem({ label: itemLabel }); const mergedRef = useForkRef(forwardedRef, listItem.ref, setItem); + useEnhancedEffect(() => { + if (listItem.index === -1) { + return undefined; + } + + const values = valuesRef.current; + values[listItem.index] = valueProp; + + return () => { + values[listItem.index] = null; + }; + }, [listItem.index, valueProp, valuesRef]); + const id = useId(idProp); const highlighted = listItem.index === activeIndex; const selected = listItem.index === selectedIndex; const handleSelect = useEventCallback(() => { - setSelectedIndex(listItem.index); + setValue(valueProp); }); const contextValue = React.useMemo(() => ({ open, selected }), [open, selected]); @@ -139,7 +155,7 @@ const SelectItem = React.forwardRef(function SelectItem( ); }); -interface InnerSelectItemProps extends SelectItem.Props { +interface InnerSelectItemProps extends Omit { highlighted: boolean; selected: boolean; getItemProps: UseInteractionsReturn['getItemProps']; @@ -164,25 +180,29 @@ namespace SelectItem { export interface Props extends BaseUIComponentProps<'div', OwnerState> { children?: React.ReactNode; /** - * The click handler for the menu item. + * The value of the select item. + */ + value: string; + /** + * The click handler for the select item. */ onClick?: React.MouseEventHandler; /** - * If `true`, the menu item will be disabled. + * If `true`, the select item will be disabled. * @default false */ disabled?: boolean; /** - * A text representation of the menu item's content. + * A text representation of the select item's content. * Used for keyboard text navigation matching. */ label?: string; /** - * The id of the menu item. + * The id of the select item. */ id?: string; /** - * If `true`, the menu will close when the menu item is clicked. + * If `true`, the select will close when the select item is clicked. * * @default true */ @@ -200,27 +220,27 @@ SelectItem.propTypes /* remove-proptypes */ = { */ children: PropTypes.node, /** - * If `true`, the menu will close when the menu item is clicked. + * If `true`, the select will close when the select item is clicked. * * @default true */ closeOnClick: PropTypes.bool, /** - * If `true`, the menu item will be disabled. + * If `true`, the select item will be disabled. * @default false */ disabled: PropTypes.bool, /** - * The id of the menu item. + * The id of the select item. */ id: PropTypes.string, /** - * A text representation of the menu item's content. + * A text representation of the select item's content. * Used for keyboard text navigation matching. */ label: PropTypes.string, /** - * The click handler for the menu item. + * The click handler for the select item. */ onClick: PropTypes.func, } as any; diff --git a/packages/mui-base/src/Select/Item/useSelectItem.ts b/packages/mui-base/src/Select/Item/useSelectItem.ts index 08c8900062..621a738962 100644 --- a/packages/mui-base/src/Select/Item/useSelectItem.ts +++ b/packages/mui-base/src/Select/Item/useSelectItem.ts @@ -16,7 +16,6 @@ export function useSelectItem(params: useSelectItem.Parameters): useSelectItem.R const { disabled = false, highlighted, - selected, id, ref: externalRef, setOpen, @@ -69,11 +68,7 @@ export function useSelectItem(params: useSelectItem.Parameters): useSelectItem.R return; } - if (selected) { - if (selectionRef.current.select) { - commitSelection(event.nativeEvent); - } - } else { + if (selectionRef.current.select) { commitSelection(event.nativeEvent); } @@ -82,7 +77,7 @@ export function useSelectItem(params: useSelectItem.Parameters): useSelectItem.R }), ); }, - [commitSelection, getButtonProps, highlighted, id, selected, selectionRef, typingRef], + [commitSelection, getButtonProps, highlighted, id, selectionRef, typingRef], ); return React.useMemo( diff --git a/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx b/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx index 392d06c143..aea398b479 100644 --- a/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx +++ b/packages/mui-base/src/Select/Positioner/SelectPositioner.tsx @@ -16,6 +16,7 @@ import { useComponentRenderer } from '../../utils/useComponentRenderer'; import { useForkRef } from '../../utils/useForkRef'; import { useSelectPositioner } from './useSelectPositioner'; import { HTMLElementType } from '../../utils/proptypes'; +import { visuallyHidden } from '../../utils/visuallyHidden'; /** * Renders the element that positions the Select popup. @@ -65,7 +66,7 @@ const SelectPositioner = React.forwardRef(function SelectPositioner( alignMethod, innerFallback, setInnerFallback, - selectedIndexOnMount, + selectedIndex, } = useSelectRootContext(); const positioner = useSelectPositioner({ @@ -87,14 +88,14 @@ const SelectPositioner = React.forwardRef(function SelectPositioner( allowAxisFlip: false, innerFallback, inner: - alignMethod === 'selected-item' && selectedIndexOnMount !== null + alignMethod === 'selected-item' && selectedIndex !== null ? // Dependency-injected for tree-shaking purposes. Other floating element components don't // use or need this. inner({ boundary: collisionBoundary, padding: collisionPadding, listRef: elementsRef, - index: selectedIndexOnMount, + index: selectedIndex, scrollRef: popupRef, offset: innerOffset, onFallbackChange(fallbackValue) { @@ -150,21 +151,42 @@ const SelectPositioner = React.forwardRef(function SelectPositioner( extraProps: otherProps, }); + const positionerElement = renderElement(); + + const selectItems = positionerElement.props.children.props.children as React.ReactElement[]; + const mountedItemsElement = ; + const nativeSelectElement = ( + + ); + const shouldRender = keepMounted || mounted; if (!shouldRender) { - return null; + return ( + + + {nativeSelectElement} + {mountedItemsElement} + + + ); } return ( + {nativeSelectElement} - {renderElement()} + {positionerElement} diff --git a/packages/mui-base/src/Select/Root/SelectRoot.tsx b/packages/mui-base/src/Select/Root/SelectRoot.tsx index 6c81ed64ec..3eb55dacef 100644 --- a/packages/mui-base/src/Select/Root/SelectRoot.tsx +++ b/packages/mui-base/src/Select/Root/SelectRoot.tsx @@ -8,6 +8,8 @@ function SelectRoot(props: SelectRoot.Props) { const { animated = true, children, + value, + defaultValue, defaultOpen = false, disabled = false, loop = true, @@ -24,6 +26,8 @@ function SelectRoot(props: SelectRoot.Props) { defaultOpen, open, alignMethod, + value, + defaultValue, }); const context: SelectRootContext = React.useMemo( @@ -48,6 +52,14 @@ namespace SelectRoot { */ animated?: boolean; children: React.ReactNode; + /** + * The value of the select. + */ + value?: string; + /** + * The default value of the select. + */ + defaultValue?: string; /** * If `true`, the Menu is initially open. * diff --git a/packages/mui-base/src/Select/Root/useSelectRoot.tsx b/packages/mui-base/src/Select/Root/useSelectRoot.tsx index d27b8913cd..e49b895674 100644 --- a/packages/mui-base/src/Select/Root/useSelectRoot.tsx +++ b/packages/mui-base/src/Select/Root/useSelectRoot.tsx @@ -1,5 +1,6 @@ 'use client'; import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { useClick, useDismiss, @@ -18,6 +19,7 @@ import { useTransitionStatus } from '../../utils/useTransitionStatus'; import { useEventCallback } from '../../utils/useEventCallback'; import { useAnimationsFinished } from '../../utils/useAnimationsFinished'; import { useControlled } from '../../utils/useControlled'; +import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; const EMPTY_ARRAY: never[] = []; @@ -35,7 +37,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R onOpenChange, disabled, loop, - value, + value: valueProp, onValueChange, defaultValue, alignMethod, @@ -44,7 +46,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElement] = React.useState(null); const [activeIndex, setActiveIndex] = React.useState(null); - const [selectedIndex, setSelectedIndexUnwrapped] = React.useState(null); + const [selectedIndex, setSelectedIndex] = React.useState(null); const [innerOffset, setInnerOffset] = React.useState(0); const [innerFallback, setInnerFallback] = React.useState(false); const [selectedIndexOnMount, setSelectedIndexOnMount] = React.useState(null); @@ -54,6 +56,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R const typingRef = React.useRef(false); const elementsRef = React.useRef>([]); const labelsRef = React.useRef>([]); + const valuesRef = React.useRef>([]); const selectionRef = React.useRef({ mouseUp: false, select: false }); const overflowRef = React.useRef({ top: 0, bottom: 0, left: 0, right: 0 }); @@ -64,31 +67,44 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R state: 'open', }); - if (!open) { - if (innerOffset !== 0) { - setInnerOffset(0); - } - if (innerFallback) { - setInnerFallback(false); - } - } else if (selectedIndexOnMount !== selectedIndex) { + if (open && selectedIndex !== selectedIndexOnMount) { setSelectedIndexOnMount(selectedIndex); } - const [selectedValue, setSelectedValueUnwrapped] = useControlled({ - controlled: value, + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, default: defaultValue, name: 'Select', - state: 'selectedValue', + state: 'value', }); - const setSelectedIndex = useEventCallback((index: number | null) => { - const nextValue = index === null ? '' : labelsRef.current[index] || ''; - setSelectedValueUnwrapped(nextValue); + const [label, setLabel] = React.useState(null); + + const setValue: useSelectRoot.ReturnValue['setValue'] = useEventCallback((nextValue) => { onValueChange?.(nextValue); - setSelectedIndexUnwrapped(index); + setValueUnwrapped(nextValue); + + if (nextValue !== null) { + const index = valuesRef.current.indexOf(nextValue); + setSelectedIndex(index); + setLabel(labelsRef.current[index]); + } else { + setSelectedIndex(null); + setLabel(null); + } }); + useEnhancedEffect(() => { + // Wait for the items to have registered their values in `valuesRef`. + queueMicrotask(() => { + const index = valuesRef.current.indexOf(value); + if (index !== -1) { + setSelectedIndex(index); + setLabel(labelsRef.current[index]); + } + }); + }, [value]); + const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, animated); const runOnceAnimationsFinish = useAnimationsFinished(popupRef); @@ -98,7 +114,12 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R setOpenUnwrapped(nextOpen); function handleUnmounted() { - setMounted(false); + // Prevents the position from visibly changing upon selection when the Select is closed. + ReactDOM.flushSync(() => { + setMounted(false); + setInnerOffset(0); + setInnerFallback(false); + }); } if (!nextOpen) { @@ -149,10 +170,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R onMatch: open ? setActiveIndex : (index) => { - const nextValue = index === null ? '' : labelsRef.current[index] || ''; - setSelectedValueUnwrapped(nextValue); - onValueChange?.(nextValue); - setSelectedIndexUnwrapped(index); + setValue(valuesRef.current[index]); }, onTypingChange(typing) { typingRef.current = typing; @@ -187,12 +205,15 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R return React.useMemo( () => ({ + value, + setValue, + label, + setLabel, activeIndex, setActiveIndex, selectedIndex, setSelectedIndex, selectedIndexOnMount, - selectedValue, floatingRootContext, triggerElement, setTriggerElement, @@ -202,6 +223,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R getItemProps, elementsRef, labelsRef, + valuesRef, mounted, transitionStatus, popupRef, @@ -216,11 +238,12 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelectRoot.R setInnerFallback, }), [ + value, + label, + setValue, activeIndex, selectedIndex, - setSelectedIndex, selectedIndexOnMount, - selectedValue, floatingRootContext, triggerElement, getTriggerProps, @@ -264,25 +287,29 @@ export namespace useSelectRoot { * If `true`, the Select is disabled. */ disabled: boolean; - value?: string; - onValueChange?: (value: string) => void; + value?: string | null; + onValueChange?: (value: string | null) => void; defaultValue?: string; alignMethod?: 'trigger' | 'selected-item'; } export interface ReturnValue { + value: string | null; + setValue: (value: string | null) => void; + label: string | null; + setLabel: React.Dispatch>; activeIndex: number | null; setActiveIndex: React.Dispatch>; selectedIndex: number | null; - setSelectedIndex: (index: number | null) => void; + setSelectedIndex: React.Dispatch>; selectedIndexOnMount: number | null; - selectedValue: string | null; floatingRootContext: FloatingRootContext; getItemProps: UseInteractionsReturn['getItemProps']; getPopupProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; getTriggerProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps; elementsRef: React.MutableRefObject<(HTMLElement | null)[]>; labelsRef: React.MutableRefObject<(string | null)[]>; + valuesRef: React.MutableRefObject<(string | null)[]>; mounted: boolean; open: boolean; popupRef: React.RefObject; diff --git a/packages/mui-base/src/Select/Trigger/useSelectTrigger.ts b/packages/mui-base/src/Select/Trigger/useSelectTrigger.ts index 0a2dd0cca9..df4210a40a 100644 --- a/packages/mui-base/src/Select/Trigger/useSelectTrigger.ts +++ b/packages/mui-base/src/Select/Trigger/useSelectTrigger.ts @@ -19,7 +19,7 @@ export function useSelectTrigger( ): useSelectTrigger.ReturnValue { const { disabled = false, rootRef: externalRef } = parameters; - const { selectedValue, open, setOpen, setTriggerElement, selectionRef, popupRef, backdropRef } = + const { open, setOpen, setTriggerElement, selectionRef, popupRef, backdropRef, label } = useSelectRootContext(); const triggerRef = React.useRef(null); @@ -54,7 +54,7 @@ export function useSelectTrigger( const getTriggerProps = React.useCallback( (externalProps?: GenericHTMLProps): GenericHTMLProps => { return mergeReactProps<'button'>( - { ...externalProps, children: selectedValue ?? externalProps?.children }, + { ...externalProps, children: label ?? externalProps?.children }, { tabIndex: 0, // this is needed to make the button focused after click in Safari ref: handleRef, @@ -84,7 +84,7 @@ export function useSelectTrigger( getButtonProps(), ); }, - [selectedValue, handleRef, getButtonProps, open, backdropRef, popupRef, setOpen], + [label, handleRef, getButtonProps, open, backdropRef, popupRef, setOpen], ); return React.useMemo(