UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

244 lines (243 loc) 8.44 kB
import * as React from 'react'; import { useClick, useDismiss, useFloatingRootContext, useInteractions, useListNavigation, useRole, useTypeahead } from '@floating-ui/react'; import { useFieldControlValidation } from '../../field/control/useFieldControlValidation.js'; import { useFieldRootContext } from '../../field/root/FieldRootContext.js'; import { useBaseUiId } from '../../utils/useBaseUiId.js'; import { useControlled } from '../../utils/useControlled.js'; import { useTransitionStatus } from '../../utils/index.js'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect.js'; import { useEventCallback } from '../../utils/useEventCallback.js'; import { warn } from '../../utils/warn.js'; import { useAfterExitAnimation } from '../../utils/useAfterExitAnimation.js'; export function useSelectRoot(params) { const { id: idProp, disabled = false, readOnly = false, required = false, alignItemToTrigger: alignItemToTriggerParam = true, modal = false } = params; const { setDirty, validityData, validationMode, setControlId } = useFieldRootContext(); const fieldControlValidation = useFieldControlValidation(); const id = useBaseUiId(idProp); useEnhancedEffect(() => { setControlId(id); return () => { setControlId(undefined); }; }, [id, setControlId]); const [value, setValueUnwrapped] = useControlled({ controlled: params.value, default: params.defaultValue, name: 'Select', state: 'value' }); const [open, setOpenUnwrapped] = useControlled({ controlled: params.open, default: params.defaultOpen, name: 'Select', state: 'open' }); const [controlledAlignItemToTrigger, setcontrolledAlignItemToTrigger] = React.useState(alignItemToTriggerParam); const listRef = React.useRef([]); const labelsRef = React.useRef([]); const popupRef = React.useRef(null); const valueRef = React.useRef(null); const valuesRef = React.useRef([]); const typingRef = React.useRef(false); const selectedItemTextRef = React.useRef(null); const selectionRef = React.useRef({ allowSelectedMouseUp: false, allowUnselectedMouseUp: false, allowSelect: false }); const [triggerElement, setTriggerElement] = React.useState(null); const [positionerElement, setPositionerElement] = React.useState(null); const [activeIndex, setActiveIndex] = React.useState(null); const [selectedIndex, setSelectedIndex] = React.useState(null); const [label, setLabel] = React.useState(''); const [touchModality, setTouchModality] = React.useState(false); const [scrollUpArrowVisible, setScrollUpArrowVisible] = React.useState(false); const [scrollDownArrowVisible, setScrollDownArrowVisible] = React.useState(false); const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); const alignItemToTrigger = Boolean(mounted && controlledAlignItemToTrigger && !touchModality); if (!mounted && controlledAlignItemToTrigger !== alignItemToTriggerParam) { setcontrolledAlignItemToTrigger(alignItemToTriggerParam); } if (!alignItemToTriggerParam || !mounted) { if (scrollUpArrowVisible) { setScrollUpArrowVisible(false); } if (scrollDownArrowVisible) { setScrollDownArrowVisible(false); } } const setOpen = useEventCallback((nextOpen, event) => { params.onOpenChange?.(nextOpen, event); setOpenUnwrapped(nextOpen); // Workaround `enableFocusInside` in Floating UI setting `tabindex=0` of a non-highlighted // option upon close when tabbing out due to `keepMounted=true`: // https://github.com/floating-ui/floating-ui/pull/3004/files#diff-962a7439cdeb09ea98d4b622a45d517bce07ad8c3f866e089bda05f4b0bbd875R194-R199 // This otherwise causes options to retain `tabindex=0` incorrectly when the popup is closed // when tabbing outside. if (!nextOpen && activeIndex !== null) { const activeOption = listRef.current[activeIndex]; // Wait for Floating UI's focus effect to have fired queueMicrotask(() => { activeOption?.setAttribute('tabindex', '-1'); }); } }); useAfterExitAnimation({ open, animatedElementRef: popupRef, onFinished() { setMounted(false); setActiveIndex(null); } }); const setValue = useEventCallback((nextValue, event) => { params.onValueChange?.(nextValue, event); setValueUnwrapped(nextValue); setDirty(nextValue !== validityData.initialValue); if (validationMode === 'onChange') { fieldControlValidation.commitValidation(nextValue); } const index = valuesRef.current.indexOf(nextValue); setSelectedIndex(index); setLabel(labelsRef.current[index] ?? ''); }); useEnhancedEffect(() => { // Wait for the items to have registered their values in `valuesRef`. queueMicrotask(() => { const stringValue = typeof value === 'string' || value === null ? value : JSON.stringify(value); const index = valuesRef.current.indexOf(stringValue); if (index !== -1) { setSelectedIndex(index); setLabel(labelsRef.current[index] ?? ''); } else if (value) { warn(`The value \`${stringValue}\` is not present in the select items.`); } }); }, [value]); const floatingRootContext = useFloatingRootContext({ open, onOpenChange: setOpen, elements: { reference: triggerElement, floating: positionerElement } }); const click = useClick(floatingRootContext, { enabled: !readOnly, event: 'mousedown' }); const dismiss = useDismiss(floatingRootContext, { bubbles: false, outsidePressEvent: 'mousedown' }); const role = useRole(floatingRootContext, { role: 'select' }); const listNavigation = useListNavigation(floatingRootContext, { enabled: !readOnly, listRef, activeIndex, selectedIndex, onNavigate(nextActiveIndex) { // Retain the highlight while transitioning out. if (nextActiveIndex === null && !open) { return; } setActiveIndex(nextActiveIndex); }, // Implement our own listeners since `onPointerLeave` on each option fires while scrolling with // the `alignItemToTrigger` prop enabled, causing a performance issue on Chrome. focusItemOnHover: false }); const typehaead = useTypeahead(floatingRootContext, { enabled: !readOnly, listRef: labelsRef, activeIndex, selectedIndex, onMatch(index) { if (open) { setActiveIndex(index); } else { setValue(valuesRef.current[index]); } }, onTypingChange(typing) { // FIXME: Floating UI doesn't support allowing space to select an item while the popup is // closed and the trigger isn't a native <button>. typingRef.current = typing; } }); const { getReferenceProps: getRootTriggerProps, getFloatingProps: getRootPositionerProps, getItemProps } = useInteractions([click, dismiss, role, listNavigation, typehaead]); const rootContext = React.useMemo(() => ({ id, name: params.name, required, disabled, readOnly, triggerElement, setTriggerElement, positionerElement, setPositionerElement, scrollUpArrowVisible, setScrollUpArrowVisible, scrollDownArrowVisible, setScrollDownArrowVisible, setcontrolledAlignItemToTrigger, value, setValue, open, setOpen, mounted, setMounted, label, setLabel, valueRef, valuesRef, labelsRef, typingRef, selectionRef, getRootPositionerProps, getRootTriggerProps, getItemProps, listRef, popupRef, selectedItemTextRef, floatingRootContext, touchModality, setTouchModality, alignItemToTrigger, transitionStatus, fieldControlValidation, modal }), [id, params.name, required, disabled, readOnly, triggerElement, positionerElement, scrollUpArrowVisible, scrollDownArrowVisible, value, setValue, open, setOpen, mounted, setMounted, label, getRootPositionerProps, getRootTriggerProps, getItemProps, floatingRootContext, touchModality, alignItemToTrigger, transitionStatus, fieldControlValidation, modal]); const indexContext = React.useMemo(() => ({ activeIndex, setActiveIndex, selectedIndex, setSelectedIndex }), [activeIndex, selectedIndex, setActiveIndex]); return React.useMemo(() => ({ rootContext, indexContext }), [rootContext, indexContext]); }