@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
JavaScript
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]);
}