@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.
395 lines (387 loc) • 13.8 kB
JavaScript
import * as React from 'react';
import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit';
import { useOnFirstRender } from '@base-ui-components/utils/useOnFirstRender';
import { useControlled } from '@base-ui-components/utils/useControlled';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useEventCallback } from '@base-ui-components/utils/useEventCallback';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { warn } from '@base-ui-components/utils/warn';
import { useLatestRef } from '@base-ui-components/utils/useLatestRef';
import { useStore, Store } from '@base-ui-components/utils/store';
import { useClick, useDismiss, useFloatingRootContext, useInteractions, useListNavigation, useRole, useTypeahead } from "../../floating-ui-react/index.js";
import { useFieldControlValidation } from "../../field/control/useFieldControlValidation.js";
import { useFieldRootContext } from "../../field/root/FieldRootContext.js";
import { useBaseUiId } from "../../utils/useBaseUiId.js";
import { useTransitionStatus } from "../../utils/useTransitionStatus.js";
import { selectors } from "../store.js";
import { translateOpenChangeReason } from "../../utils/translateOpenChangeReason.js";
import { useOpenChangeComplete } from "../../utils/useOpenChangeComplete.js";
import { useFormContext } from "../../form/FormContext.js";
import { useField } from "../../field/useField.js";
import { EMPTY_ARRAY } from "../../utils/constants.js";
export function useSelectRoot(params) {
const {
id: idProp,
disabled: disabledProp = false,
readOnly = false,
required = false,
modal = false,
name: nameProp,
onOpenChangeComplete,
items,
multiple = false
} = params;
const {
clearErrors
} = useFormContext();
const {
setDirty,
validityData,
validationMode,
setControlId,
setFilled,
name: fieldName,
disabled: fieldDisabled
} = useFieldRootContext();
const fieldControlValidation = useFieldControlValidation();
const id = useBaseUiId(idProp);
const disabled = fieldDisabled || disabledProp;
const name = fieldName ?? nameProp;
useIsoLayoutEffect(() => {
setControlId(id);
return () => {
setControlId(undefined);
};
}, [id, setControlId]);
const [value, setValueUnwrapped] = useControlled({
controlled: params.value,
default: multiple ? params.defaultValue ?? EMPTY_ARRAY : params.defaultValue,
name: 'Select',
state: 'value'
});
const [open, setOpenUnwrapped] = useControlled({
controlled: params.open,
default: params.defaultOpen,
name: 'Select',
state: 'open'
});
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 keyboardActiveRef = React.useRef(false);
const selectedItemTextRef = React.useRef(null);
const lastSelectedIndexRef = React.useRef(null);
const selectionRef = React.useRef({
allowSelectedMouseUp: false,
allowUnselectedMouseUp: false,
allowSelect: false
});
const hasRegisteredRef = React.useRef(false);
const alignItemWithTriggerActiveRef = React.useRef(false);
const highlightTimeout = useTimeout();
const {
mounted,
setMounted,
transitionStatus
} = useTransitionStatus(open);
const store = useRefWithInit(() => new Store({
id,
modal,
multiple,
value,
label: '',
open,
mounted,
forceMount: false,
transitionStatus,
items,
touchModality: false,
activeIndex: null,
selectedIndex: null,
popupProps: {},
triggerProps: {},
triggerElement: null,
positionerElement: null,
scrollUpArrowVisible: false,
scrollDownArrowVisible: false
})).current;
const initialValueRef = React.useRef(value);
useIsoLayoutEffect(() => {
// Ensure the values and labels are registered for programmatic value changes.
if (value !== initialValueRef.current) {
store.set('forceMount', true);
}
}, [store, value]);
const activeIndex = useStore(store, selectors.activeIndex);
const selectedIndex = useStore(store, selectors.selectedIndex);
const triggerElement = useStore(store, selectors.triggerElement);
const positionerElement = useStore(store, selectors.positionerElement);
const controlRef = useLatestRef(store.state.triggerElement);
const commitValidation = fieldControlValidation.commitValidation;
useField({
id,
commitValidation,
value,
controlRef,
name,
getValue: () => value
});
const prevValueRef = React.useRef(value);
useIsoLayoutEffect(() => {
setFilled(value !== null);
}, [value, setFilled]);
useIsoLayoutEffect(() => {
if (prevValueRef.current === value) {
return;
}
if (multiple) {
// For multiple selection, update the label and keep track of the last selected
// item via `selectedIndex`, which is needed when the popup (re)opens.
const currentValue = Array.isArray(value) ? value : [];
const labels = currentValue.map(v => {
const index = valuesRef.current.indexOf(v);
return index !== -1 ? labelsRef.current[index] ?? '' : '';
}).filter(Boolean);
const lastValue = currentValue[currentValue.length - 1];
const lastIndex = lastValue != null ? valuesRef.current.indexOf(lastValue) : -1;
// Store the last selected index for later use when closing the popup.
lastSelectedIndexRef.current = lastIndex === -1 ? null : lastIndex;
store.apply({
label: labels.join(', ')
});
} else {
const index = valuesRef.current.indexOf(value);
store.apply({
selectedIndex: index === -1 ? null : index,
label: labelsRef.current[index] ?? ''
});
}
clearErrors(name);
setDirty(value !== validityData.initialValue);
commitValidation(value, validationMode !== 'onChange');
if (validationMode === 'onChange') {
commitValidation(value);
}
}, [value, commitValidation, clearErrors, name, validationMode, store, setDirty, validityData.initialValue, setFilled, multiple]);
useIsoLayoutEffect(() => {
prevValueRef.current = value;
}, [value]);
const setOpen = useEventCallback((nextOpen, event, reason) => {
params.onOpenChange?.(nextOpen, event, reason);
setOpenUnwrapped(nextOpen);
// The active index will sync to the last selected index on the next open.
if (!nextOpen && multiple) {
store.set('selectedIndex', lastSelectedIndexRef.current);
}
// 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 && store.state.activeIndex !== null) {
const activeOption = listRef.current[store.state.activeIndex];
// Wait for Floating UI's focus effect to have fired
queueMicrotask(() => {
activeOption?.setAttribute('tabindex', '-1');
});
}
});
const handleUnmount = useEventCallback(() => {
setMounted(false);
store.set('activeIndex', null);
onOpenChangeComplete?.(false);
});
useOpenChangeComplete({
enabled: !params.actionsRef,
open,
ref: popupRef,
onComplete() {
if (!open) {
handleUnmount();
}
}
});
React.useImperativeHandle(params.actionsRef, () => ({
unmount: handleUnmount
}), [handleUnmount]);
const setValue = useEventCallback((nextValue, event) => {
params.onValueChange?.(nextValue, event);
setValueUnwrapped(nextValue);
});
/**
* Keeps `store.selectedIndex` and `store.label` in sync with the current `value`.
* Does nothing until at least one item has reported its index (so that
* `valuesRef`/`labelsRef` are populated).
*/
const syncSelectedState = useEventCallback(() => {
if (!hasRegisteredRef.current) {
return;
}
if (multiple) {
const currentValue = Array.isArray(value) ? value : [];
const labels = currentValue.map(v => {
const index = valuesRef.current.indexOf(v);
return index !== -1 ? labelsRef.current[index] ?? '' : '';
}).filter(Boolean);
const lastValue = currentValue[currentValue.length - 1];
const lastIndex = lastValue !== undefined ? valuesRef.current.indexOf(lastValue) : -1;
// Store the last selected index for later use when closing the popup.
lastSelectedIndexRef.current = lastIndex === -1 ? null : lastIndex;
let computedSelectedIndex = store.state.selectedIndex;
if (computedSelectedIndex === null) {
computedSelectedIndex = lastIndex === -1 ? null : lastIndex;
}
store.apply({
selectedIndex: computedSelectedIndex,
label: labels.join(', ')
});
} else {
const index = valuesRef.current.indexOf(value);
const hasIndex = index !== -1;
if (hasIndex || value === null) {
store.apply({
selectedIndex: hasIndex ? index : null,
label: hasIndex ? labelsRef.current[index] ?? '' : ''
});
return;
}
if (process.env.NODE_ENV !== 'production') {
if (value) {
const stringValue = typeof value === 'string' || value === null ? value : JSON.stringify(value);
warn(`The value \`${stringValue}\` is not present in the select items.`);
}
}
}
});
/**
* Called by each <Select.Item> once it knows its stable index. After the first
* call, the root is able to resolve labels and selected indices.
*/
const registerItemIndex = useEventCallback(index => {
hasRegisteredRef.current = true;
if (multiple) {
// Store the last selected item index so that the popup can restore focus
// when it re-opens.
lastSelectedIndexRef.current = index;
}
syncSelectedState();
});
// Keep store in sync whenever `value` changes after registration.
useIsoLayoutEffect(syncSelectedState, [value, syncSelectedState]);
const floatingContext = useFloatingRootContext({
open,
onOpenChange(nextOpen, event, reason) {
setOpen(nextOpen, event, translateOpenChangeReason(reason));
},
elements: {
reference: triggerElement,
floating: positionerElement
}
});
const click = useClick(floatingContext, {
enabled: !readOnly && !disabled,
event: 'mousedown'
});
const dismiss = useDismiss(floatingContext, {
bubbles: false
});
const role = useRole(floatingContext, {
role: 'select'
});
const listNavigation = useListNavigation(floatingContext, {
enabled: !readOnly && !disabled,
listRef,
activeIndex,
selectedIndex,
disabledIndices: EMPTY_ARRAY,
onNavigate(nextActiveIndex) {
// Retain the highlight while transitioning out.
if (nextActiveIndex === null && !open) {
return;
}
store.set('activeIndex', nextActiveIndex);
},
// Implement our own listeners since `onPointerLeave` on each option fires while scrolling with
// the `alignItemWithTrigger=true`, causing a performance issue on Chrome.
focusItemOnHover: false
});
const typeahead = useTypeahead(floatingContext, {
enabled: !readOnly && !disabled && (open || !multiple),
listRef: labelsRef,
activeIndex,
selectedIndex,
onMatch(index) {
if (open) {
store.set('activeIndex', 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,
getFloatingProps,
getItemProps
} = useInteractions([click, dismiss, role, listNavigation, typeahead]);
useOnFirstRender(() => {
// These should be initialized at store creation, but there is an interdependency
// between some values used in floating hooks above.
store.apply({
popupProps: getFloatingProps(),
triggerProps: getReferenceProps()
});
});
// Store values that depend on other hooks
React.useEffect(() => {
store.apply({
id,
modal,
multiple,
value,
open,
mounted,
transitionStatus,
popupProps: getFloatingProps(),
triggerProps: getReferenceProps()
});
}, [store, id, modal, multiple, value, open, mounted, transitionStatus, getFloatingProps, getReferenceProps]);
const rootContext = React.useMemo(() => ({
store,
name,
required,
disabled,
readOnly,
multiple,
setValue,
setOpen,
listRef,
popupRef,
getItemProps,
events: floatingContext.events,
valueRef,
valuesRef,
labelsRef,
typingRef,
selectionRef,
selectedItemTextRef,
fieldControlValidation,
registerItemIndex,
onOpenChangeComplete,
keyboardActiveRef,
alignItemWithTriggerActiveRef,
highlightTimeout
}), [store, name, required, disabled, readOnly, multiple, setValue, setOpen, listRef, popupRef, getItemProps, floatingContext.events, valueRef, valuesRef, labelsRef, typingRef, selectionRef, selectedItemTextRef, fieldControlValidation, registerItemIndex, onOpenChangeComplete, keyboardActiveRef, alignItemWithTriggerActiveRef, highlightTimeout]);
return {
rootContext,
floatingContext,
value
};
}