@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.
357 lines (351 loc) • 15.1 kB
JavaScript
'use client';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { isWebKit } from '@base-ui-components/utils/detectBrowser';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { ownerDocument, ownerWindow } from '@base-ui-components/utils/owner';
import { isMouseWithinBounds } from '@base-ui-components/utils/isMouseWithinBounds';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useStore } from '@base-ui-components/utils/store';
import { useAnimationFrame } from '@base-ui-components/utils/useAnimationFrame';
import { FloatingFocusManager } from "../../floating-ui-react/index.js";
import { useSelectFloatingContext, useSelectRootContext } from "../root/SelectRootContext.js";
import { popupStateMapping } from "../../utils/popupStateMapping.js";
import { useSelectPositionerContext } from "../positioner/SelectPositionerContext.js";
import { styleDisableScrollbar } from "../../utils/styles.js";
import { transitionStatusMapping } from "../../utils/stateAttributesMapping.js";
import { useOpenChangeComplete } from "../../utils/useOpenChangeComplete.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { selectors } from "../store.js";
import { clearStyles, LIST_FUNCTIONAL_STYLES } from "./utils.js";
import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { useToolbarRootContext } from "../../toolbar/root/ToolbarRootContext.js";
import { COMPOSITE_KEYS } from "../../composite/composite.js";
import { getDisabledMountTransitionStyles } from "../../utils/getDisabledMountTransitionStyles.js";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const stateAttributesMapping = {
...popupStateMapping,
...transitionStatusMapping
};
/**
* A container for the select list.
* Renders a `<div>` element.
*
* Documentation: [Base UI Select](https://base-ui.com/react/components/select)
*/
export const SelectPopup = /*#__PURE__*/React.forwardRef(function SelectPopup(componentProps, forwardedRef) {
const {
render,
className,
...elementProps
} = componentProps;
const {
store,
popupRef,
onOpenChangeComplete,
setOpen,
valueRef,
selectedItemTextRef,
keyboardActiveRef,
multiple,
handleScrollArrowVisibility,
scrollHandlerRef
} = useSelectRootContext();
const {
side,
align,
alignItemWithTriggerActive,
setControlledAlignItemWithTrigger,
scrollDownArrowRef,
scrollUpArrowRef
} = useSelectPositionerContext();
const insideToolbar = useToolbarRootContext(true) != null;
const floatingRootContext = useSelectFloatingContext();
const highlightTimeout = useTimeout();
const id = useStore(store, selectors.id);
const open = useStore(store, selectors.open);
const mounted = useStore(store, selectors.mounted);
const popupProps = useStore(store, selectors.popupProps);
const transitionStatus = useStore(store, selectors.transitionStatus);
const triggerElement = useStore(store, selectors.triggerElement);
const positionerElement = useStore(store, selectors.positionerElement);
const listElement = useStore(store, selectors.listElement);
const initialHeightRef = React.useRef(0);
const reachedMaxHeightRef = React.useRef(false);
const maxHeightRef = React.useRef(0);
const initialPlacedRef = React.useRef(false);
const originalPositionerStylesRef = React.useRef({});
const scrollArrowFrame = useAnimationFrame();
const handleScroll = useStableCallback(scroller => {
if (!positionerElement || !popupRef.current || !initialPlacedRef.current) {
return;
}
if (reachedMaxHeightRef.current || !alignItemWithTriggerActive) {
handleScrollArrowVisibility();
return;
}
const isTopPositioned = positionerElement.style.top === '0px';
const isBottomPositioned = positionerElement.style.bottom === '0px';
const currentHeight = positionerElement.getBoundingClientRect().height;
const doc = ownerDocument(positionerElement);
const positionerStyles = getComputedStyle(positionerElement);
const marginTop = parseFloat(positionerStyles.marginTop);
const marginBottom = parseFloat(positionerStyles.marginBottom);
const viewportHeight = doc.documentElement.clientHeight - marginTop - marginBottom;
const scrollTop = scroller.scrollTop;
const scrollHeight = scroller.scrollHeight;
const clientHeight = scroller.clientHeight;
const maxScrollTop = scrollHeight - clientHeight;
let nextPositionerHeight = null;
let nextScrollTop = null;
let setReachedMax = false;
if (isTopPositioned) {
const diff = maxScrollTop - scrollTop;
const idealHeight = currentHeight + diff;
const nextHeight = Math.min(idealHeight, viewportHeight);
nextPositionerHeight = nextHeight;
if (nextHeight !== viewportHeight) {
nextScrollTop = maxScrollTop;
} else {
setReachedMax = true;
}
} else if (isBottomPositioned) {
const diff = scrollTop - 0;
const idealHeight = currentHeight + diff;
const nextHeight = Math.min(idealHeight, viewportHeight);
const overshoot = idealHeight - viewportHeight;
nextPositionerHeight = nextHeight;
if (nextHeight !== viewportHeight) {
nextScrollTop = 0;
} else {
setReachedMax = true;
if (scrollTop < maxScrollTop) {
nextScrollTop = scrollTop - (diff - overshoot);
}
}
}
if (nextPositionerHeight != null) {
positionerElement.style.height = `${nextPositionerHeight}px`;
}
if (nextScrollTop != null) {
scroller.scrollTop = nextScrollTop;
}
if (setReachedMax) {
reachedMaxHeightRef.current = true;
}
handleScrollArrowVisibility();
});
React.useImperativeHandle(scrollHandlerRef, () => handleScroll, [handleScroll]);
useOpenChangeComplete({
open,
ref: popupRef,
onComplete() {
if (open) {
onOpenChangeComplete?.(true);
}
}
});
const state = React.useMemo(() => ({
open,
transitionStatus,
side,
align
}), [open, transitionStatus, side, align]);
useIsoLayoutEffect(() => {
if (!positionerElement || !popupRef.current || Object.keys(originalPositionerStylesRef.current).length) {
return;
}
originalPositionerStylesRef.current = {
top: positionerElement.style.top || '0',
left: positionerElement.style.left || '0',
right: positionerElement.style.right,
height: positionerElement.style.height,
bottom: positionerElement.style.bottom,
minHeight: positionerElement.style.minHeight,
maxHeight: positionerElement.style.maxHeight,
marginTop: positionerElement.style.marginTop,
marginBottom: positionerElement.style.marginBottom
};
}, [popupRef, positionerElement]);
useIsoLayoutEffect(() => {
if (mounted || alignItemWithTriggerActive) {
return;
}
initialPlacedRef.current = false;
reachedMaxHeightRef.current = false;
initialHeightRef.current = 0;
maxHeightRef.current = 0;
clearStyles(positionerElement, originalPositionerStylesRef.current);
}, [mounted, alignItemWithTriggerActive, positionerElement, popupRef]);
useIsoLayoutEffect(() => {
const popupElement = popupRef.current;
if (!mounted || !triggerElement || !positionerElement || !popupElement) {
return;
}
if (!alignItemWithTriggerActive) {
initialPlacedRef.current = true;
scrollArrowFrame.request(handleScrollArrowVisibility);
return;
}
// Wait for `selectedItemTextRef.current` to be set.
queueMicrotask(() => {
const positionerStyles = getComputedStyle(positionerElement);
const popupStyles = getComputedStyle(popupElement);
const doc = ownerDocument(triggerElement);
const win = ownerWindow(positionerElement);
const triggerRect = triggerElement.getBoundingClientRect();
const positionerRect = positionerElement.getBoundingClientRect();
const triggerX = triggerRect.left;
const triggerHeight = triggerRect.height;
const scroller = listElement || popupElement;
const scrollHeight = scroller.scrollHeight;
const borderBottom = parseFloat(popupStyles.borderBottomWidth);
const marginTop = parseFloat(positionerStyles.marginTop) || 10;
const marginBottom = parseFloat(positionerStyles.marginBottom) || 10;
const minHeight = parseFloat(positionerStyles.minHeight) || 100;
const paddingLeft = 5;
const paddingRight = 5;
const triggerCollisionThreshold = 20;
const viewportHeight = doc.documentElement.clientHeight - marginTop - marginBottom;
const viewportWidth = doc.documentElement.clientWidth;
const availableSpaceBeneathTrigger = viewportHeight - triggerRect.bottom + triggerHeight;
const textElement = selectedItemTextRef.current;
const valueElement = valueRef.current;
let offsetX = 0;
let offsetY = 0;
if (textElement && valueElement) {
const valueRect = valueElement.getBoundingClientRect();
const textRect = textElement.getBoundingClientRect();
const valueLeftFromTriggerLeft = valueRect.left - triggerX;
const textLeftFromPositionerLeft = textRect.left - positionerRect.left;
const valueCenterFromPositionerTop = valueRect.top - triggerRect.top + valueRect.height / 2;
const textCenterFromTriggerTop = textRect.top - positionerRect.top + textRect.height / 2;
offsetX = valueLeftFromTriggerLeft - textLeftFromPositionerLeft;
offsetY = textCenterFromTriggerTop - valueCenterFromPositionerTop;
}
const idealHeight = availableSpaceBeneathTrigger + offsetY + marginBottom + borderBottom;
let height = Math.min(viewportHeight, idealHeight);
const maxHeight = viewportHeight - marginTop - marginBottom;
const scrollTop = idealHeight - height;
const left = Math.max(paddingLeft, triggerX + offsetX);
const maxRight = viewportWidth - paddingRight;
const rightOverflow = Math.max(0, left + positionerRect.width - maxRight);
positionerElement.style.left = `${left - rightOverflow}px`;
positionerElement.style.height = `${height}px`;
positionerElement.style.maxHeight = 'auto';
positionerElement.style.marginTop = `${marginTop}px`;
positionerElement.style.marginBottom = `${marginBottom}px`;
popupElement.style.height = '100%';
const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
const isTopPositioned = scrollTop >= maxScrollTop;
if (isTopPositioned) {
height = Math.min(viewportHeight, positionerRect.height) - (scrollTop - maxScrollTop);
}
// When the trigger is too close to the top or bottom of the viewport, or the minHeight is
// reached, we fallback to aligning the popup to the trigger as the UX is poor otherwise.
const fallbackToAlignPopupToTrigger = triggerRect.top < triggerCollisionThreshold || triggerRect.bottom > viewportHeight - triggerCollisionThreshold || height < Math.min(scrollHeight, minHeight);
// Safari doesn't position the popup correctly when pinch-zoomed.
const isPinchZoomed = (win.visualViewport?.scale ?? 1) !== 1 && isWebKit;
if (fallbackToAlignPopupToTrigger || isPinchZoomed) {
initialPlacedRef.current = true;
clearStyles(positionerElement, originalPositionerStylesRef.current);
ReactDOM.flushSync(() => setControlledAlignItemWithTrigger(false));
return;
}
if (isTopPositioned) {
const topOffset = Math.max(0, viewportHeight - idealHeight);
positionerElement.style.top = positionerRect.height >= maxHeight ? '0' : `${topOffset}px`;
positionerElement.style.height = `${height}px`;
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
initialHeightRef.current = Math.max(minHeight, height);
} else {
positionerElement.style.bottom = '0';
initialHeightRef.current = Math.max(minHeight, height);
scroller.scrollTop = scrollTop;
}
if (initialHeightRef.current === viewportHeight) {
reachedMaxHeightRef.current = true;
}
handleScrollArrowVisibility();
// Avoid the `onScroll` event logic from triggering before the popup is placed.
setTimeout(() => {
initialPlacedRef.current = true;
});
});
}, [store, mounted, positionerElement, triggerElement, valueRef, selectedItemTextRef, popupRef, handleScrollArrowVisibility, alignItemWithTriggerActive, setControlledAlignItemWithTrigger, scrollArrowFrame, scrollDownArrowRef, scrollUpArrowRef, listElement]);
React.useEffect(() => {
if (!alignItemWithTriggerActive || !positionerElement || !mounted) {
return undefined;
}
const win = ownerWindow(positionerElement);
function handleResize(event) {
setOpen(false, createChangeEventDetails(REASONS.windowResize, event));
}
win.addEventListener('resize', handleResize);
return () => {
win.removeEventListener('resize', handleResize);
};
}, [setOpen, alignItemWithTriggerActive, positionerElement, mounted]);
const defaultProps = {
...(listElement ? {
role: 'presentation',
'aria-orientation': undefined
} : {
role: 'listbox',
'aria-multiselectable': multiple || undefined,
id: `${id}-list`
}),
onKeyDown(event) {
keyboardActiveRef.current = true;
if (insideToolbar && COMPOSITE_KEYS.has(event.key)) {
event.stopPropagation();
}
},
onMouseMove() {
keyboardActiveRef.current = false;
},
onPointerLeave(event) {
if (isMouseWithinBounds(event) || event.pointerType === 'touch') {
return;
}
const popup = event.currentTarget;
highlightTimeout.start(0, () => {
store.set('activeIndex', null);
popup.focus({
preventScroll: true
});
});
},
onScroll(event) {
if (listElement) {
return;
}
scrollHandlerRef.current?.(event.currentTarget);
},
...(alignItemWithTriggerActive && {
style: listElement ? {
height: '100%'
} : LIST_FUNCTIONAL_STYLES
})
};
const element = useRenderElement('div', componentProps, {
ref: [forwardedRef, popupRef],
state,
stateAttributesMapping,
props: [popupProps, defaultProps, getDisabledMountTransitionStyles(transitionStatus), {
className: !listElement && alignItemWithTriggerActive ? styleDisableScrollbar.className : undefined
}, elementProps]
});
return /*#__PURE__*/_jsxs(React.Fragment, {
children: [styleDisableScrollbar.element, /*#__PURE__*/_jsx(FloatingFocusManager, {
context: floatingRootContext,
modal: false,
disabled: !mounted,
restoreFocus: true,
children: element
})]
});
});
if (process.env.NODE_ENV !== "production") SelectPopup.displayName = "SelectPopup";