From cf3e715988076c1672f49db4b473209958045f98 Mon Sep 17 00:00:00 2001
From: Albert Yu
Date: Tue, 7 Jan 2025 15:53:39 +0800
Subject: [PATCH 1/3] [Slider] Use un-rounded values to position thumbs (#1219)
---
docs/reference/generated/slider-root.json | 4 +
docs/reference/generated/slider-thumb.json | 5 +
.../src/slider/control/SliderControl.test.tsx | 20 +-
.../src/slider/control/SliderControl.tsx | 12 +-
.../src/slider/control/useSliderControl.ts | 144 +++---
.../slider/indicator/SliderIndicator.test.tsx | 20 +-
.../src/slider/indicator/SliderIndicator.tsx | 3 +-
.../slider/indicator/useSliderIndicator.ts | 5 +-
packages/react/src/slider/root/SliderRoot.tsx | 79 ++--
.../src/slider/root/SliderRootContext.ts | 1 +
.../react/src/slider/root/useSliderRoot.ts | 437 ++++++++----------
.../src/slider/thumb/SliderThumb.test.tsx | 20 +-
.../react/src/slider/thumb/SliderThumb.tsx | 55 ++-
.../react/src/slider/thumb/useSliderThumb.ts | 110 ++---
.../src/slider/track/SliderTrack.test.tsx | 20 +-
.../react/src/slider/utils/getSliderValue.ts | 32 +-
.../slider/utils/replaceArrayItemAtIndex.ts | 7 +
.../{utils.ts => utils/roundValueToStep.ts} | 4 -
.../react/src/slider/utils/setValueIndex.ts | 15 -
.../src/slider/value/SliderValue.test.tsx | 20 +-
.../react/src/slider/value/SliderValue.tsx | 13 +-
.../react/src/slider/value/useSliderValue.ts | 11 +-
22 files changed, 510 insertions(+), 527 deletions(-)
create mode 100644 packages/react/src/slider/utils/replaceArrayItemAtIndex.ts
rename packages/react/src/slider/{utils.ts => utils/roundValueToStep.ts} (86%)
delete mode 100644 packages/react/src/slider/utils/setValueIndex.ts
diff --git a/docs/reference/generated/slider-root.json b/docs/reference/generated/slider-root.json
index 804dc5ff26..d432e1c472 100644
--- a/docs/reference/generated/slider-root.json
+++ b/docs/reference/generated/slider-root.json
@@ -26,6 +26,10 @@
"type": "(value, event) => void",
"description": "Callback function that is fired when the `pointerup` is triggered."
},
+ "tabIndex": {
+ "type": "number",
+ "description": "Optional tab index attribute for the thumb components."
+ },
"step": {
"type": "number",
"default": "1",
diff --git a/docs/reference/generated/slider-thumb.json b/docs/reference/generated/slider-thumb.json
index 558237081c..88568a6eda 100644
--- a/docs/reference/generated/slider-thumb.json
+++ b/docs/reference/generated/slider-thumb.json
@@ -18,6 +18,11 @@
"type": "function(formattedValue: string, value: number, index: number) => string",
"description": "Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider.\nThis is important for screen reader users."
},
+ "tabIndex": {
+ "type": "number",
+ "default": "null",
+ "description": "Optional tab index attribute for the thumb components."
+ },
"className": {
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
diff --git a/packages/react/src/slider/control/SliderControl.test.tsx b/packages/react/src/slider/control/SliderControl.test.tsx
index da57da3d3a..4e55a7a590 100644
--- a/packages/react/src/slider/control/SliderControl.test.tsx
+++ b/packages/react/src/slider/control/SliderControl.test.tsx
@@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop';
const testRootContext: SliderRootContext = {
active: -1,
- areValuesEqual: () => true,
- changeValue: NOOP,
- direction: 'ltr',
+ handleInputChange: NOOP,
dragging: false,
disabled: false,
- getFingerNewValue: () => ({
- newValue: 0,
- activeIndex: 0,
- newPercentageValue: 0,
+ getFingerState: () => ({
+ value: 0,
+ valueRescaled: 0,
+ percentageValues: [0],
+ thumbIndex: 0,
}),
- handleValueChange: NOOP,
+ setValue: NOOP,
largeStep: 10,
thumbMap: new Map(),
max: 100,
min: 0,
minStepsBetweenValues: 0,
+ name: '',
+ onValueCommitted: NOOP,
orientation: 'horizontal',
state: {
activeThumbIndex: -1,
@@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = {
registerSliderControl: NOOP,
setActive: NOOP,
setDragging: NOOP,
+ setPercentageValues: NOOP,
setThumbMap: NOOP,
- setValueState: NOOP,
step: 1,
+ tabIndex: null,
thumbRefs: { current: [] },
values: [0],
};
diff --git a/packages/react/src/slider/control/SliderControl.tsx b/packages/react/src/slider/control/SliderControl.tsx
index 21e5529fa0..46e449ee6f 100644
--- a/packages/react/src/slider/control/SliderControl.tsx
+++ b/packages/react/src/slider/control/SliderControl.tsx
@@ -21,11 +21,10 @@ const SliderControl = React.forwardRef(function SliderControl(
const { render: renderProp, className, ...otherProps } = props;
const {
- areValuesEqual,
disabled,
dragging,
- getFingerNewValue,
- handleValueChange,
+ getFingerState,
+ setValue,
minStepsBetweenValues,
onValueCommitted,
state,
@@ -33,17 +32,15 @@ const SliderControl = React.forwardRef(function SliderControl(
registerSliderControl,
setActive,
setDragging,
- setValueState,
step,
thumbRefs,
} = useSliderRootContext();
const { getRootProps } = useSliderControl({
- areValuesEqual,
disabled,
dragging,
- getFingerNewValue,
- handleValueChange,
+ getFingerState,
+ setValue,
minStepsBetweenValues,
onValueCommitted,
percentageValues,
@@ -51,7 +48,6 @@ const SliderControl = React.forwardRef(function SliderControl(
rootRef: forwardedRef,
setActive,
setDragging,
- setValueState,
step,
thumbRefs,
});
diff --git a/packages/react/src/slider/control/useSliderControl.ts b/packages/react/src/slider/control/useSliderControl.ts
index 28470dbae2..ef6fd13269 100644
--- a/packages/react/src/slider/control/useSliderControl.ts
+++ b/packages/react/src/slider/control/useSliderControl.ts
@@ -7,23 +7,49 @@ import { useForkRef } from '../../utils/useForkRef';
import { useEventCallback } from '../../utils/useEventCallback';
import {
focusThumb,
- trackFinger,
- type useSliderRoot,
validateMinimumDistance,
+ type FingerPosition,
+ type useSliderRoot,
} from '../root/useSliderRoot';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
const INTENTIONAL_DRAG_COUNT_THRESHOLD = 2;
+function trackFinger(
+ event: TouchEvent | PointerEvent | React.PointerEvent,
+ touchIdRef: React.RefObject,
+): FingerPosition | null {
+ // The event is TouchEvent
+ if (touchIdRef.current !== undefined && (event as TouchEvent).changedTouches) {
+ const touchEvent = event as TouchEvent;
+ for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
+ const touch = touchEvent.changedTouches[i];
+ if (touch.identifier === touchIdRef.current) {
+ return {
+ x: touch.clientX,
+ y: touch.clientY,
+ };
+ }
+ }
+
+ return null;
+ }
+
+ // The event is PointerEvent
+ return {
+ x: (event as PointerEvent).clientX,
+ y: (event as PointerEvent).clientY,
+ };
+}
+
export function useSliderControl(
parameters: useSliderControl.Parameters,
): useSliderControl.ReturnValue {
const {
- areValuesEqual,
disabled,
dragging,
- getFingerNewValue,
- handleValueChange,
+ getFingerState,
+ setValue,
onValueCommitted,
minStepsBetweenValues,
percentageValues,
@@ -31,7 +57,6 @@ export function useSliderControl(
rootRef: externalRef,
setActive,
setDragging,
- setValueState,
step,
thumbRefs,
} = parameters;
@@ -44,18 +69,17 @@ export function useSliderControl(
// A number that uniquely identifies the current finger in the touch session.
const touchIdRef = React.useRef(null);
-
const moveCountRef = React.useRef(0);
-
- // offset distance between:
- // 1. pointerDown coordinates and
- // 2. the exact intersection of the center of the thumb and the track
+ /**
+ * The difference between the value at the finger origin and the value at
+ * the center of the thumb scaled down to fit the range [0, 1]
+ */
const offsetRef = React.useRef(0);
const handleTouchMove = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => {
- const finger = trackFinger(nativeEvent, touchIdRef);
+ const fingerPosition = trackFinger(nativeEvent, touchIdRef);
- if (!finger) {
+ if (fingerPosition == null) {
return;
}
@@ -69,57 +93,42 @@ export function useSliderControl(
return;
}
- const newFingerValue = getFingerNewValue({
- finger,
- move: true,
- offset: offsetRef.current,
- });
+ const finger = getFingerState(fingerPosition, false, offsetRef.current);
- if (!newFingerValue) {
+ if (finger == null) {
return;
}
- const { newValue, activeIndex } = newFingerValue;
-
- focusThumb({ sliderRef: controlRef, activeIndex, setActive });
-
- if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
- setValueState(newValue);
+ focusThumb(finger.thumbIndex, controlRef, setActive);
+ if (validateMinimumDistance(finger.value, step, minStepsBetweenValues)) {
if (!dragging && moveCountRef.current > INTENTIONAL_DRAG_COUNT_THRESHOLD) {
setDragging(true);
}
- if (handleValueChange && !areValuesEqual(newValue)) {
- handleValueChange(newValue, activeIndex, nativeEvent);
- }
+ setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent);
}
});
const handleTouchEnd = useEventCallback((nativeEvent: TouchEvent | PointerEvent) => {
- const finger = trackFinger(nativeEvent, touchIdRef);
+ const fingerPosition = trackFinger(nativeEvent, touchIdRef);
setDragging(false);
- if (!finger) {
+ if (fingerPosition == null) {
return;
}
- const newFingerValue = getFingerNewValue({
- finger,
- move: true,
- });
+ const finger = getFingerState(fingerPosition, false);
- if (!newFingerValue) {
+ if (finger == null) {
return;
}
setActive(-1);
- commitValidation(newFingerValue.newValue);
+ commitValidation(finger.value);
- if (onValueCommitted) {
- onValueCommitted(newFingerValue.newValue, nativeEvent);
- }
+ onValueCommitted(finger.value, nativeEvent);
touchIdRef.current = null;
@@ -138,25 +147,18 @@ export function useSliderControl(
touchIdRef.current = touch.identifier;
}
- const finger = trackFinger(nativeEvent, touchIdRef);
+ const fingerPosition = trackFinger(nativeEvent, touchIdRef);
- if (finger !== false) {
- const newFingerValue = getFingerNewValue({
- finger,
- });
+ if (fingerPosition != null) {
+ const finger = getFingerState(fingerPosition, true);
- if (!newFingerValue) {
+ if (finger == null) {
return;
}
- const { newValue, activeIndex } = newFingerValue;
- focusThumb({ sliderRef: controlRef, activeIndex, setActive });
+ focusThumb(finger.thumbIndex, controlRef, setActive);
- setValueState(newValue);
-
- if (handleValueChange && !areValuesEqual(newValue)) {
- handleValueChange(newValue, activeIndex, nativeEvent);
- }
+ setValue(finger.value, finger.percentageValues, finger.thumbIndex, nativeEvent);
}
moveCountRef.current = 0;
@@ -218,36 +220,24 @@ export function useSliderControl(
// Avoid text selection
event.preventDefault();
- const finger = trackFinger(event, touchIdRef);
+ const fingerPosition = trackFinger(event, touchIdRef);
- if (finger !== false) {
- const newFingerValue = getFingerNewValue({
- finger,
- });
+ if (fingerPosition != null) {
+ const finger = getFingerState(fingerPosition, true);
- if (!newFingerValue) {
+ if (finger == null) {
return;
}
- const { newValue, activeIndex, newPercentageValue } = newFingerValue;
-
- focusThumb({ sliderRef: controlRef, activeIndex, setActive });
+ focusThumb(finger.thumbIndex, controlRef, setActive);
// if the event lands on a thumb, don't change the value, just get the
// percentageValue difference represented by the distance between the click origin
// and the coordinates of the value on the track area
if (thumbRefs.current.includes(event.target as HTMLElement)) {
- const targetThumbIndex = (event.target as HTMLElement).getAttribute('data-index');
-
- const offset = percentageValues[Number(targetThumbIndex)] / 100 - newPercentageValue;
-
- offsetRef.current = offset;
+ offsetRef.current = percentageValues[finger.thumbIndex] / 100 - finger.valueRescaled;
} else {
- setValueState(newValue);
-
- if (handleValueChange && !areValuesEqual(newValue)) {
- handleValueChange(newValue, activeIndex, event);
- }
+ setValue(finger.value, finger.percentageValues, finger.thumbIndex, event.nativeEvent);
}
}
@@ -260,16 +250,14 @@ export function useSliderControl(
});
},
[
- areValuesEqual,
disabled,
- getFingerNewValue,
+ getFingerState,
handleRootRef,
handleTouchMove,
handleTouchEnd,
- handleValueChange,
+ setValue,
percentageValues,
setActive,
- setValueState,
thumbRefs,
],
);
@@ -286,18 +274,16 @@ export namespace useSliderControl {
export interface Parameters
extends Pick<
useSliderRoot.ReturnValue,
- | 'areValuesEqual'
| 'disabled'
| 'dragging'
- | 'getFingerNewValue'
- | 'handleValueChange'
+ | 'getFingerState'
+ | 'setValue'
| 'minStepsBetweenValues'
| 'onValueCommitted'
| 'percentageValues'
| 'registerSliderControl'
| 'setActive'
| 'setDragging'
- | 'setValueState'
| 'step'
| 'thumbRefs'
> {
diff --git a/packages/react/src/slider/indicator/SliderIndicator.test.tsx b/packages/react/src/slider/indicator/SliderIndicator.test.tsx
index fa84166aba..04eb7806f9 100644
--- a/packages/react/src/slider/indicator/SliderIndicator.test.tsx
+++ b/packages/react/src/slider/indicator/SliderIndicator.test.tsx
@@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop';
const testRootContext: SliderRootContext = {
active: -1,
- areValuesEqual: () => true,
- changeValue: NOOP,
- direction: 'ltr',
+ handleInputChange: NOOP,
dragging: false,
disabled: false,
- getFingerNewValue: () => ({
- newValue: 0,
- activeIndex: 0,
- newPercentageValue: 0,
+ getFingerState: () => ({
+ value: 0,
+ valueRescaled: 0,
+ percentageValues: [0],
+ thumbIndex: 0,
}),
- handleValueChange: NOOP,
+ setValue: NOOP,
largeStep: 10,
thumbMap: new Map(),
max: 100,
min: 0,
minStepsBetweenValues: 0,
+ name: '',
+ onValueCommitted: NOOP,
orientation: 'horizontal',
state: {
activeThumbIndex: -1,
@@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = {
registerSliderControl: NOOP,
setActive: NOOP,
setDragging: NOOP,
+ setPercentageValues: NOOP,
setThumbMap: NOOP,
- setValueState: NOOP,
step: 1,
+ tabIndex: null,
thumbRefs: { current: [] },
values: [0],
};
diff --git a/packages/react/src/slider/indicator/SliderIndicator.tsx b/packages/react/src/slider/indicator/SliderIndicator.tsx
index 7c09a0ae60..653d6d67b8 100644
--- a/packages/react/src/slider/indicator/SliderIndicator.tsx
+++ b/packages/react/src/slider/indicator/SliderIndicator.tsx
@@ -20,10 +20,9 @@ const SliderIndicator = React.forwardRef(function SliderIndicator(
) {
const { render, className, ...otherProps } = props;
- const { direction, disabled, orientation, state, percentageValues } = useSliderRootContext();
+ const { disabled, orientation, state, percentageValues } = useSliderRootContext();
const { getRootProps } = useSliderIndicator({
- direction,
disabled,
orientation,
percentageValues,
diff --git a/packages/react/src/slider/indicator/useSliderIndicator.ts b/packages/react/src/slider/indicator/useSliderIndicator.ts
index e8271ccb91..b8cef2d682 100644
--- a/packages/react/src/slider/indicator/useSliderIndicator.ts
+++ b/packages/react/src/slider/indicator/useSliderIndicator.ts
@@ -71,10 +71,7 @@ export function useSliderIndicator(
export namespace useSliderIndicator {
export interface Parameters
- extends Pick<
- useSliderRoot.ReturnValue,
- 'direction' | 'disabled' | 'orientation' | 'percentageValues'
- > {}
+ extends Pick {}
export interface ReturnValue {
getRootProps: (externalProps?: GenericHTMLProps) => GenericHTMLProps;
diff --git a/packages/react/src/slider/root/SliderRoot.tsx b/packages/react/src/slider/root/SliderRoot.tsx
index 22e18422f0..b0d56ac5b0 100644
--- a/packages/react/src/slider/root/SliderRoot.tsx
+++ b/packages/react/src/slider/root/SliderRoot.tsx
@@ -1,11 +1,12 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
+import { NOOP } from '../../utils/noop';
import type { BaseUIComponentProps } from '../../utils/types';
+import { useBaseUiId } from '../../utils/useBaseUiId';
import { useComponentRenderer } from '../../utils/useComponentRenderer';
import type { FieldRoot } from '../../field/root/FieldRoot';
import { CompositeList } from '../../composite/list/CompositeList';
-import { useDirection } from '../../direction-provider/DirectionContext';
import { sliderStyleHookMapping } from './styleHooks';
import { useSliderRoot } from './useSliderRoot';
import { SliderRootContext } from './SliderRootContext';
@@ -26,45 +27,43 @@ const SliderRoot = React.forwardRef(function SliderRoot(
className,
defaultValue,
disabled: disabledProp = false,
- id,
+ id: idProp,
format,
- largeStep,
+ largeStep = 10,
render,
- max,
- min,
- minStepsBetweenValues,
- name,
- onValueChange,
- onValueCommitted,
+ max = 100,
+ min = 0,
+ minStepsBetweenValues = 0,
+ name: nameProp,
+ onValueChange: onValueChangeProp,
+ onValueCommitted: onValueCommittedProp,
orientation = 'horizontal',
- step,
- tabIndex,
+ step = 1,
+ tabIndex: externalTabIndex,
value,
...otherProps
} = props;
- const direction = useDirection();
+ const id = useBaseUiId(idProp);
const { labelId, state: fieldState, disabled: fieldDisabled } = useFieldRootContext();
const disabled = fieldDisabled || disabledProp;
const { getRootProps, ...slider } = useSliderRoot({
- 'aria-labelledby': ariaLabelledby ?? labelId,
+ 'aria-labelledby': ariaLabelledby ?? labelId ?? '',
defaultValue,
- direction,
disabled,
- id,
+ id: id ?? '',
largeStep,
max,
min,
minStepsBetweenValues,
- name,
- onValueChange,
- onValueCommitted,
+ name: nameProp ?? '',
+ onValueChange: onValueChangeProp ?? NOOP,
+ onValueCommitted: onValueCommittedProp ?? NOOP,
orientation,
rootRef: forwardedRef,
step,
- tabIndex,
value,
});
@@ -100,8 +99,9 @@ const SliderRoot = React.forwardRef(function SliderRoot(
...slider,
format,
state,
+ tabIndex: externalTabIndex ?? null,
}),
- [slider, format, state],
+ [slider, format, state, externalTabIndex],
);
const { renderElement } = useComponentRenderer({
@@ -154,23 +154,24 @@ export namespace SliderRoot {
/**
* The raw number value of the slider.
*/
- values: ReadonlyArray;
+ values: readonly number[];
}
export interface Props
- extends Pick<
- useSliderRoot.Parameters,
- | 'disabled'
- | 'max'
- | 'min'
- | 'minStepsBetweenValues'
- | 'name'
- | 'onValueChange'
- | 'onValueCommitted'
- | 'orientation'
- | 'largeStep'
- | 'step'
- | 'value'
+ extends Partial<
+ Pick<
+ useSliderRoot.Parameters,
+ | 'disabled'
+ | 'max'
+ | 'min'
+ | 'minStepsBetweenValues'
+ | 'name'
+ | 'onValueChange'
+ | 'onValueCommitted'
+ | 'orientation'
+ | 'largeStep'
+ | 'step'
+ >
>,
Omit, 'defaultValue' | 'onChange' | 'values'> {
/**
@@ -178,7 +179,7 @@ export namespace SliderRoot {
*
* To render a controlled slider, use the `value` prop instead.
*/
- defaultValue?: number | ReadonlyArray;
+ defaultValue?: number | readonly number[];
/**
* Whether the component should ignore user interaction.
* @default false
@@ -188,11 +189,15 @@ export namespace SliderRoot {
* Options to format the input value.
*/
format?: Intl.NumberFormatOptions;
+ /**
+ * Optional tab index attribute for the thumb components.
+ */
+ tabIndex?: number;
/**
* The value of the slider.
* For ranged sliders, provide an array with two values.
*/
- value?: number | ReadonlyArray;
+ value?: number | readonly number[];
}
}
@@ -317,7 +322,7 @@ SliderRoot.propTypes /* remove-proptypes */ = {
*/
step: PropTypes.number,
/**
- * @ignore
+ * Optional tab index attribute for the thumb components.
*/
tabIndex: PropTypes.number,
/**
diff --git a/packages/react/src/slider/root/SliderRootContext.ts b/packages/react/src/slider/root/SliderRootContext.ts
index a2b3c5f3dd..521bad010a 100644
--- a/packages/react/src/slider/root/SliderRootContext.ts
+++ b/packages/react/src/slider/root/SliderRootContext.ts
@@ -6,6 +6,7 @@ import type { useSliderRoot } from './useSliderRoot';
export interface SliderRootContext extends Omit {
format?: Intl.NumberFormatOptions;
state: SliderRoot.State;
+ tabIndex: number | null;
}
export const SliderRootContext = React.createContext(undefined);
diff --git a/packages/react/src/slider/root/useSliderRoot.ts b/packages/react/src/slider/root/useSliderRoot.ts
index 6860b7a608..5dcdc0687b 100644
--- a/packages/react/src/slider/root/useSliderRoot.ts
+++ b/packages/react/src/slider/root/useSliderRoot.ts
@@ -7,21 +7,34 @@ import { mergeReactProps } from '../../utils/mergeReactProps';
import { ownerDocument } from '../../utils/owner';
import { useControlled } from '../../utils/useControlled';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
+import { useEventCallback } from '../../utils/useEventCallback';
import { useForkRef } from '../../utils/useForkRef';
-import { useBaseUiId } from '../../utils/useBaseUiId';
import { valueToPercent } from '../../utils/valueToPercent';
import type { CompositeMetadata } from '../../composite/list/CompositeList';
-import type { TextDirection } from '../../direction-provider/DirectionContext';
+import { useDirection } from '../../direction-provider/DirectionContext';
import { useField } from '../../field/useField';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
-import { percentToValue, roundValueToStep } from '../utils';
import { asc } from '../utils/asc';
-import { setValueIndex } from '../utils/setValueIndex';
import { getSliderValue } from '../utils/getSliderValue';
+import { replaceArrayItemAtIndex } from '../utils/replaceArrayItemAtIndex';
+import { roundValueToStep } from '../utils/roundValueToStep';
import { ThumbMetadata } from '../thumb/useSliderThumb';
-function findClosest(values: number[], currentValue: number) {
+function areValuesEqual(
+ newValue: number | readonly number[],
+ oldValue: number | readonly number[],
+) {
+ if (typeof newValue === 'number' && typeof oldValue === 'number') {
+ return newValue === oldValue;
+ }
+ if (Array.isArray(newValue) && Array.isArray(oldValue)) {
+ return areArraysEqual(newValue, oldValue);
+ }
+ return false;
+}
+
+function findClosest(values: readonly number[], currentValue: number) {
const { index: closestIndex } =
values.reduce<{ distance: number; index: number } | null>(
(acc, value: number, index: number) => {
@@ -41,25 +54,30 @@ function findClosest(values: number[], currentValue: number) {
return closestIndex;
}
-export function focusThumb({
- sliderRef,
- activeIndex,
- setActive,
-}: {
- sliderRef: React.RefObject;
- activeIndex: number;
- setActive?: (num: number) => void;
-}) {
+export function focusThumb(
+ thumbIndex: number,
+ sliderRef: React.RefObject,
+ setActive?: useSliderRoot.ReturnValue['setActive'],
+) {
+ if (!sliderRef.current) {
+ return;
+ }
+
const doc = ownerDocument(sliderRef.current);
+
if (
- !sliderRef.current?.contains(doc.activeElement) ||
- Number(doc?.activeElement?.getAttribute('data-index')) !== activeIndex
+ !sliderRef.current.contains(doc.activeElement) ||
+ Number(doc?.activeElement?.getAttribute('data-index')) !== thumbIndex
) {
- sliderRef.current?.querySelector(`[type="range"][data-index="${activeIndex}"]`).focus();
+ (
+ sliderRef.current.querySelector(
+ `[type="range"][data-index="${thumbIndex}"]`,
+ ) as HTMLInputElement
+ ).focus();
}
if (setActive) {
- setActive(activeIndex);
+ setActive(thumbIndex);
}
}
@@ -85,42 +103,14 @@ export function validateMinimumDistance(
return Math.min(...distances) >= step * minStepsBetweenValues;
}
-export function trackFinger(
- event: TouchEvent | PointerEvent | React.PointerEvent,
- touchIdRef: React.RefObject,
-) {
- // The event is TouchEvent
- if (touchIdRef.current !== undefined && (event as TouchEvent).changedTouches) {
- const touchEvent = event as TouchEvent;
- for (let i = 0; i < touchEvent.changedTouches.length; i += 1) {
- const touch = touchEvent.changedTouches[i];
- if (touch.identifier === touchIdRef.current) {
- return {
- x: touch.clientX,
- y: touch.clientY,
- };
- }
- }
-
- return false;
- }
-
- // The event is PointerEvent
- return {
- x: (event as PointerEvent).clientX,
- y: (event as PointerEvent).clientY,
- };
-}
-
/**
*/
export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRoot.ReturnValue {
const {
'aria-labelledby': ariaLabelledby,
defaultValue,
- direction = 'ltr',
disabled = false,
- id: idProp,
+ id,
largeStep = 10,
max = 100,
min = 0,
@@ -131,10 +121,10 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
orientation = 'horizontal',
rootRef,
step = 1,
- tabIndex,
value: valueProp,
} = parameters;
+ const direction = useDirection();
const { setControlId, setTouched, setDirty, validityData } = useFieldRootContext();
const {
@@ -143,7 +133,9 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
commitValidation,
} = useFieldControlValidation();
- const [valueState, setValueState] = useControlled({
+ // The internal value is potentially unsorted, e.g. to support frozen arrays
+ // https://github.com/mui/material-ui/pull/28472
+ const [valueUnwrapped, setValueUnwrapped] = useControlled({
controlled: valueProp,
default: defaultValue ?? min,
name: 'Slider',
@@ -153,8 +145,6 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
const controlRef: React.RefObject = React.useRef(null);
const thumbRefs = React.useRef<(HTMLElement | null)[]>([]);
- const id = useBaseUiId(idProp);
-
const [thumbMap, setThumbMap] = React.useState(
() => new Map | null>(),
);
@@ -169,7 +159,7 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
useField({
id,
commitValidation,
- value: valueState,
+ value: valueUnwrapped,
controlRef,
});
@@ -190,115 +180,109 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
[inputValidationRef],
);
- const handleValueChange = React.useCallback(
- (value: number | number[], thumbIndex: number, event: Event | React.SyntheticEvent) => {
- if (!onValueChange) {
+ const range = Array.isArray(valueUnwrapped);
+
+ const values = React.useMemo(() => {
+ if (!range) {
+ return [clamp(valueUnwrapped as number, min, max)];
+ }
+ return valueUnwrapped.slice().sort(asc);
+ }, [max, min, range, valueUnwrapped]);
+
+ function initializePercentageValues() {
+ const vals = [];
+ for (let i = 0; i < values.length; i += 1) {
+ vals.push(valueToPercent(values[i], min, max));
+ }
+ return vals;
+ }
+
+ const [percentageValues, setPercentageValues] = React.useState(
+ initializePercentageValues,
+ );
+
+ const setValue = useEventCallback(
+ (
+ newValue: number | number[],
+ newPercentageValues: readonly number[],
+ thumbIndex: number,
+ event: Event,
+ ) => {
+ if (areValuesEqual(newValue, valueUnwrapped)) {
return;
}
+ setValueUnwrapped(newValue);
+ setPercentageValues(newPercentageValues);
// Redefine target to allow name and value to be read.
// This allows seamless integration with the most popular form libraries.
// https://github.com/mui/material-ui/issues/13485#issuecomment-676048492
// Clone the event to not override `target` of the original event.
- const nativeEvent = (event as React.SyntheticEvent).nativeEvent || event;
// @ts-ignore The nativeEvent is function, not object
- const clonedEvent = new nativeEvent.constructor(nativeEvent.type, nativeEvent);
+ const clonedEvent = new event.constructor(event.type, event);
Object.defineProperty(clonedEvent, 'target', {
writable: true,
- value: { value, name },
+ value: { value: newValue, name },
});
- onValueChange(value, clonedEvent, thumbIndex);
+ onValueChange(newValue, clonedEvent, thumbIndex);
},
- [name, onValueChange],
);
- const range = Array.isArray(valueState);
-
- const values = React.useMemo(() => {
- return (range ? valueState.slice().sort(asc) : [valueState]).map((val) =>
- val == null ? min : clamp(val, min, max),
- );
- }, [max, min, range, valueState]);
-
const handleRootRef = useForkRef(rootRef, sliderRef);
- const areValuesEqual = React.useCallback(
- (newValue: number | ReadonlyArray): boolean => {
- if (typeof newValue === 'number' && typeof valueState === 'number') {
- return newValue === valueState;
- }
- if (typeof newValue === 'object' && typeof valueState === 'object') {
- return areArraysEqual(newValue, valueState);
- }
- return false;
- },
- [valueState],
- );
-
- const changeValue = React.useCallback(
+ const handleInputChange = useEventCallback(
(valueInput: number, index: number, event: React.KeyboardEvent | React.ChangeEvent) => {
- const newValue = getSliderValue({
- valueInput,
- min,
- max,
- index,
- range,
- values,
- });
+ const newValue = getSliderValue(valueInput, index, min, max, range, values);
if (range) {
- focusThumb({ sliderRef, activeIndex: index });
+ focusThumb(index, sliderRef);
}
if (validateMinimumDistance(newValue, step, minStepsBetweenValues)) {
- setValueState(newValue);
- setDirty(newValue !== validityData.initialValue);
-
- if (handleValueChange && !areValuesEqual(newValue) && event) {
- handleValueChange(newValue, index, event);
+ if (Array.isArray(newValue)) {
+ setValue(
+ newValue,
+ replaceArrayItemAtIndex(
+ percentageValues,
+ index,
+ valueToPercent(newValue[index], min, max),
+ ),
+ index,
+ event.nativeEvent,
+ );
+ } else {
+ setValue(newValue, [valueToPercent(newValue, min, max)], index, event.nativeEvent);
}
-
+ setDirty(newValue !== validityData.initialValue);
setTouched(true);
commitValidation(newValue);
-
- if (onValueCommitted && event) {
- onValueCommitted(newValue, event.nativeEvent);
- }
+ onValueCommitted(newValue, event.nativeEvent);
}
},
- [
- min,
- max,
- range,
- step,
- minStepsBetweenValues,
- values,
- setValueState,
- setDirty,
- validityData.initialValue,
- handleValueChange,
- areValuesEqual,
- onValueCommitted,
- setTouched,
- commitValidation,
- ],
);
- const previousIndexRef = React.useRef(null);
-
- const getFingerNewValue = React.useCallback(
- ({
- finger,
- move = false,
- offset = 0,
- }: {
- finger: { x: number; y: number };
- // `move` is used to distinguish between when this is called by touchstart vs touchmove/end
- move?: boolean;
- offset?: number;
- }) => {
+ const closestThumbIndexRef = React.useRef(null);
+
+ const getFingerState = useEventCallback(
+ (
+ fingerPosition: FingerPosition | null,
+ /**
+ * When `true`, closestThumbIndexRef is updated.
+ * It's `true` when called by touchstart or pointerdown.
+ */
+ shouldCaptureThumbIndex: boolean = false,
+ /**
+ * The difference between the value at the finger origin and the value at
+ * the center of the thumb scaled down to fit the range [0, 1]
+ */
+ offset: number = 0,
+ ): FingerState | null => {
+ if (fingerPosition == null) {
+ return null;
+ }
+
const { current: sliderControl } = controlRef;
if (!sliderControl) {
@@ -308,68 +292,61 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
const isRtl = direction === 'rtl';
const isVertical = orientation === 'vertical';
- const { width, height, bottom, left } = sliderControl!.getBoundingClientRect();
- let percent;
+ const { width, height, bottom, left } = sliderControl.getBoundingClientRect();
- if (isVertical) {
- percent = (bottom - finger.y) / height + offset;
- } else {
- percent = (finger.x - left) / width + offset * (isRtl ? -1 : 1);
- }
+ // the value at the finger origin scaled down to fit the range [0, 1]
+ let valueRescaled = isVertical
+ ? (bottom - fingerPosition.y) / height + offset
+ : (fingerPosition.x - left) / width + offset * (isRtl ? -1 : 1);
- percent = Math.min(percent, 1);
+ valueRescaled = clamp(valueRescaled, 0, 1);
if (isRtl && !isVertical) {
- percent = 1 - percent;
- }
-
- let newValue;
- newValue = percentToValue(percent, min, max);
- if (step) {
- newValue = roundValueToStep(newValue, step, min);
+ valueRescaled = 1 - valueRescaled;
}
+ let newValue = (max - min) * valueRescaled + min;
+ newValue = roundValueToStep(newValue, step, min);
newValue = clamp(newValue, min, max);
- let activeIndex = 0;
if (!range) {
- return { newValue, activeIndex, newPercentageValue: percent };
+ return {
+ value: newValue,
+ valueRescaled,
+ percentageValues: [valueRescaled * 100],
+ thumbIndex: 0,
+ };
}
- if (!move) {
- activeIndex = findClosest(values, newValue)!;
- } else {
- activeIndex = previousIndexRef.current!;
+ if (shouldCaptureThumbIndex) {
+ closestThumbIndexRef.current = findClosest(values, newValue) ?? 0;
}
+ const closestThumbIndex = closestThumbIndexRef.current ?? 0;
+
// Bound the new value to the thumb's neighbours.
newValue = clamp(
newValue,
- values[activeIndex - 1] + minStepsBetweenValues || -Infinity,
- values[activeIndex + 1] - minStepsBetweenValues || Infinity,
+ values[closestThumbIndex - 1] + minStepsBetweenValues || -Infinity,
+ values[closestThumbIndex + 1] - minStepsBetweenValues || Infinity,
);
- const previousValue = newValue;
- newValue = setValueIndex({
- values,
- newValue,
- index: activeIndex,
- });
-
- // Potentially swap the index if needed.
- if (!move) {
- activeIndex = newValue.indexOf(previousValue);
- previousIndexRef.current = activeIndex;
- }
-
- return { newValue, activeIndex, newPercentageValue: percent };
+ return {
+ value: replaceArrayItemAtIndex(values, closestThumbIndex, newValue),
+ valueRescaled,
+ percentageValues: replaceArrayItemAtIndex(
+ percentageValues,
+ closestThumbIndex,
+ valueRescaled * 100,
+ ),
+ thumbIndex: closestThumbIndex,
+ };
},
- [direction, max, min, minStepsBetweenValues, orientation, range, step, values],
);
useEnhancedEffect(() => {
const activeEl = activeElement(ownerDocument(sliderRef.current));
- if (disabled && sliderRef.current!.contains(activeEl)) {
+ if (disabled && sliderRef.current?.contains(activeEl)) {
// This is necessary because Firefox and Safari will keep focus
// on a disabled element:
// https://codesandbox.io/p/sandbox/mui-pr-22247-forked-h151h?file=/src/App.js
@@ -396,15 +373,12 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
return React.useMemo(
() => ({
getRootProps,
- active,
- areValuesEqual,
'aria-labelledby': ariaLabelledby,
- changeValue,
- direction,
+ active,
disabled,
dragging,
- getFingerNewValue,
- handleValueChange,
+ getFingerState,
+ handleInputChange,
largeStep,
max,
min,
@@ -412,15 +386,15 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
name,
onValueCommitted,
orientation,
- percentageValues: values.map((v) => valueToPercent(v, min, max)),
+ percentageValues,
range,
registerSliderControl,
setActive,
setDragging,
+ setPercentageValues,
setThumbMap,
- setValueState,
+ setValue,
step,
- tabIndex,
thumbMap,
thumbRefs,
values,
@@ -428,14 +402,11 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
[
getRootProps,
active,
- areValuesEqual,
ariaLabelledby,
- changeValue,
- direction,
disabled,
dragging,
- getFingerNewValue,
- handleValueChange,
+ getFingerState,
+ handleInputChange,
largeStep,
max,
min,
@@ -443,14 +414,15 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
name,
onValueCommitted,
orientation,
+ percentageValues,
range,
registerSliderControl,
setActive,
setDragging,
+ setPercentageValues,
setThumbMap,
- setValueState,
+ setValue,
step,
- tabIndex,
thumbMap,
thumbRefs,
values,
@@ -458,6 +430,18 @@ export function useSliderRoot(parameters: useSliderRoot.Parameters): useSliderRo
);
}
+export interface FingerPosition {
+ x: number;
+ y: number;
+}
+
+interface FingerState {
+ value: number | number[];
+ valueRescaled: number;
+ percentageValues: number[];
+ thumbIndex: number;
+}
+
export namespace useSliderRoot {
export type Orientation = 'horizontal' | 'vertical';
@@ -465,46 +449,41 @@ export namespace useSliderRoot {
/**
* The id of the slider element.
*/
- id?: string;
+ id: string;
/**
* The id of the element containing a label for the slider.
*/
- 'aria-labelledby'?: string;
+ 'aria-labelledby': string;
/**
* The default value. Use when the component is not controlled.
*/
- defaultValue?: number | ReadonlyArray;
- /**
- * Sets the direction. For right-to-left languages, the lowest value is on the right-hand side.
- * @default 'ltr'
- */
- direction: TextDirection;
+ defaultValue?: number | readonly number[];
/**
* Whether the component should ignore user interaction.
* @default false
*/
- disabled?: boolean;
+ disabled: boolean;
/**
* The maximum allowed value of the slider.
* Should not be equal to min.
* @default 100
*/
- max?: number;
+ max: number;
/**
* The minimum allowed value of the slider.
* Should not be equal to max.
* @default 0
*/
- min?: number;
+ min: number;
/**
* The minimum steps between values in a range slider.
* @default 0
*/
- minStepsBetweenValues?: number;
+ minStepsBetweenValues: number;
/**
* Identifies the field when a form is submitted.
*/
- name?: string;
+ name: string;
/**
* Callback function that is fired when the slider's value changed.
*
@@ -513,7 +492,7 @@ export namespace useSliderRoot {
* You can pull out the new value by accessing `event.target.value` (any).
* @param {number} activeThumbIndex Index of the currently moved thumb.
*/
- onValueChange?: (value: number | number[], event: Event, activeThumbIndex: number) => void;
+ onValueChange: (value: number | number[], event: Event, activeThumbIndex: number) => void;
/**
* Callback function that is fired when the `pointerup` is triggered.
*
@@ -521,37 +500,33 @@ export namespace useSliderRoot {
* @param {Event} event The corresponding event that initiated the change.
* **Warning**: This is a generic event not a change event.
*/
- onValueCommitted?: (value: number | number[], event: Event) => void;
+ onValueCommitted: (value: number | number[], event: Event) => void;
/**
* The component orientation.
* @default 'horizontal'
*/
- orientation?: Orientation;
+ orientation: Orientation;
/**
* The ref attached to the root of the Slider.
*/
- rootRef?: React.Ref;
+ rootRef: React.Ref;
/**
* The granularity with which the slider can step through values when using Page Up/Page Down or Shift + Arrow Up/Arrow Down.
* @default 10
*/
- largeStep?: number;
+ largeStep: number;
/**
* The granularity with which the slider can step through values. (A "discrete" slider.)
* The `min` prop serves as the origin for the valid values.
* We recommend (max - min) to be evenly divisible by the step.
* @default 1
*/
- step?: number;
- /**
- * Tab index attribute of the Thumb component's `input` element.
- */
- tabIndex?: number;
+ step: number;
/**
* The value of the slider.
* For ranged sliders, provide an array with two values.
*/
- value?: number | ReadonlyArray;
+ value?: number | readonly number[];
}
export interface ReturnValue {
@@ -562,30 +537,27 @@ export namespace useSliderRoot {
* The index of the active thumb.
*/
active: number;
- /**
- * A function that compares a new value with the internal value of the slider.
- * The internal value is potentially unsorted, e.g. to support frozen arrays: https://github.com/mui/material-ui/pull/28472
- */
- areValuesEqual: (newValue: number | ReadonlyArray) => boolean;
'aria-labelledby'?: string;
- changeValue: (
+ handleInputChange: (
valueInput: number,
index: number,
event: React.KeyboardEvent | React.ChangeEvent,
) => void;
dragging: boolean;
- direction: TextDirection;
disabled: boolean;
- getFingerNewValue: (args: {
- finger: { x: number; y: number };
- move?: boolean;
- offset?: number;
- activeIndex?: number;
- }) => { newValue: number | number[]; activeIndex: number; newPercentageValue: number } | null;
- handleValueChange: (
- value: number | number[],
+ getFingerState: (
+ fingerPosition: FingerPosition | null,
+ shouldCaptureThumbIndex?: boolean,
+ offset?: number,
+ ) => FingerState | null;
+ /**
+ * Callback to invoke change handlers after internal value state is updated.
+ */
+ setValue: (
+ newValue: number | number[],
+ newPercentageValues: readonly number[],
activeThumb: number,
- event: React.SyntheticEvent | Event,
+ event: Event,
) => void;
/**
* The large step value of the slider when incrementing or decrementing while the shift key is held,
@@ -605,8 +577,8 @@ export namespace useSliderRoot {
* The minimum steps between values in a range slider.
*/
minStepsBetweenValues: number;
- name?: string;
- onValueCommitted?: (value: number | number[], event: Event) => void;
+ name: string;
+ onValueCommitted: (value: number | number[], event: Event) => void;
/**
* The component orientation.
* @default 'horizontal'
@@ -617,10 +589,12 @@ export namespace useSliderRoot {
* The value(s) of the slider as percentages
*/
percentageValues: readonly number[];
- setActive: (activeIndex: number) => void;
- setDragging: (isDragging: boolean) => void;
- setThumbMap: (map: Map | null>) => void;
- setValueState: (newValue: number | number[]) => void;
+ setActive: React.Dispatch>;
+ setDragging: React.Dispatch>;
+ setPercentageValues: React.Dispatch>;
+ setThumbMap: React.Dispatch<
+ React.SetStateAction | null>>
+ >;
/**
* The step increment of the slider when incrementing or decrementing. It will snap
* to multiples of this value. Decimal values are supported.
@@ -629,7 +603,6 @@ export namespace useSliderRoot {
step: number;
thumbMap: Map | null>;
thumbRefs: React.MutableRefObject<(HTMLElement | null)[]>;
- tabIndex?: number;
/**
* The value(s) of the slider
*/
diff --git a/packages/react/src/slider/thumb/SliderThumb.test.tsx b/packages/react/src/slider/thumb/SliderThumb.test.tsx
index b7549e245a..55b4d7c727 100644
--- a/packages/react/src/slider/thumb/SliderThumb.test.tsx
+++ b/packages/react/src/slider/thumb/SliderThumb.test.tsx
@@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop';
const testRootContext: SliderRootContext = {
active: -1,
- areValuesEqual: () => true,
- changeValue: NOOP,
- direction: 'ltr',
+ handleInputChange: NOOP,
dragging: false,
disabled: false,
- getFingerNewValue: () => ({
- newValue: 0,
- activeIndex: 0,
- newPercentageValue: 0,
+ getFingerState: () => ({
+ value: 0,
+ valueRescaled: 0,
+ percentageValues: [0],
+ thumbIndex: 0,
}),
- handleValueChange: NOOP,
+ setValue: NOOP,
largeStep: 10,
thumbMap: new Map(),
max: 100,
min: 0,
minStepsBetweenValues: 0,
+ name: '',
+ onValueCommitted: NOOP,
orientation: 'horizontal',
state: {
activeThumbIndex: -1,
@@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = {
registerSliderControl: NOOP,
setActive: NOOP,
setDragging: NOOP,
+ setPercentageValues: NOOP,
setThumbMap: NOOP,
- setValueState: NOOP,
step: 1,
+ tabIndex: null,
thumbRefs: { current: [] },
values: [0],
};
diff --git a/packages/react/src/slider/thumb/SliderThumb.tsx b/packages/react/src/slider/thumb/SliderThumb.tsx
index 7a09f0e956..0f3b166457 100644
--- a/packages/react/src/slider/thumb/SliderThumb.tsx
+++ b/packages/react/src/slider/thumb/SliderThumb.tsx
@@ -3,8 +3,10 @@ import * as React from 'react';
import PropTypes from 'prop-types';
import { getStyleHookProps } from '../../utils/getStyleHookProps';
import { mergeReactProps } from '../../utils/mergeReactProps';
+import { NOOP } from '../../utils/noop';
import { resolveClassName } from '../../utils/resolveClassName';
import { BaseUIComponentProps } from '../../utils/types';
+import { useBaseUiId } from '../../utils/useBaseUiId';
import { useForkRef } from '../../utils/useForkRef';
import type { SliderRoot } from '../root/SliderRoot';
import { useSliderRootContext } from '../root/SliderRootContext';
@@ -40,22 +42,28 @@ const SliderThumb = React.forwardRef(function SliderThumb(
'aria-valuetext': ariaValuetext,
className,
disabled: disabledProp = false,
- getAriaLabel,
- getAriaValueText,
- id,
- inputId,
+ getAriaLabel: getAriaLabelProp,
+ getAriaValueText: getAriaValueTextProp,
+ id: idProp,
+ inputId: inputIdProp,
+ onBlur: onBlurProp,
+ onFocus: onFocusProp,
+ onKeyDown: onKeyDownProp,
+ tabIndex: tabIndexProp,
...otherProps
} = props;
+ const id = useBaseUiId(idProp);
+ const inputId = useBaseUiId(inputIdProp);
+
const render = renderProp ?? defaultRender;
const {
active: activeIndex,
'aria-labelledby': ariaLabelledby,
- changeValue,
- direction,
+ handleInputChange,
disabled: contextDisabled,
- format,
+ format = null,
largeStep,
max,
min,
@@ -65,7 +73,7 @@ const SliderThumb = React.forwardRef(function SliderThumb(
state,
percentageValues,
step,
- tabIndex,
+ tabIndex: contextTabIndex,
values,
} = useSliderRootContext();
@@ -78,27 +86,29 @@ const SliderThumb = React.forwardRef(function SliderThumb(
const { getRootProps, getThumbInputProps, disabled, index } = useSliderThumb({
active: activeIndex,
- 'aria-label': ariaLabel,
- 'aria-labelledby': ariaLabelledby,
- 'aria-valuetext': ariaValuetext,
- changeValue,
- direction,
+ 'aria-label': ariaLabel ?? '',
+ 'aria-labelledby': ariaLabelledby ?? '',
+ 'aria-valuetext': ariaValuetext ?? '',
+ handleInputChange,
disabled: disabledProp || contextDisabled,
format,
- getAriaLabel,
- getAriaValueText,
- id,
- inputId,
+ getAriaLabel: getAriaLabelProp ?? null,
+ getAriaValueText: getAriaValueTextProp ?? null,
+ id: id ?? '',
+ inputId: inputId ?? '',
largeStep,
max,
min,
minStepsBetweenValues,
name,
+ onBlur: onBlurProp ?? NOOP,
+ onFocus: onFocusProp ?? NOOP,
+ onKeyDown: onKeyDownProp ?? NOOP,
orientation,
percentageValues,
rootRef: mergedRef,
step,
- tabIndex,
+ tabIndex: tabIndexProp ?? contextTabIndex,
values,
});
@@ -145,7 +155,7 @@ export namespace SliderThumb {
export interface Props
extends Partial>,
- Omit, 'render'> {
+ Omit, 'render' | 'tabIndex'> {
onPointerLeave?: React.PointerEventHandler;
onPointerOver?: React.PointerEventHandler;
onBlur?: React.FocusEventHandler;
@@ -199,6 +209,7 @@ SliderThumb.propTypes /* remove-proptypes */ = {
* Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb
* @param {number} index The index of the input
* @returns {string}
+ * @type {((index: number) => string) | null}
*/
getAriaLabel: PropTypes.func,
/**
@@ -208,6 +219,7 @@ SliderThumb.propTypes /* remove-proptypes */ = {
* @param {number} value The thumb's numerical value.
* @param {number} index The thumb's index.
* @returns {string}
+ * @type {((formattedValue: string, value: number, index: number) => string) | null}
*/
getAriaValueText: PropTypes.func,
/**
@@ -248,4 +260,9 @@ SliderThumb.propTypes /* remove-proptypes */ = {
PropTypes.func,
PropTypes.node,
]),
+ /**
+ * Optional tab index attribute for the thumb components.
+ * @default null
+ */
+ tabIndex: PropTypes.number,
} as any;
diff --git a/packages/react/src/slider/thumb/useSliderThumb.ts b/packages/react/src/slider/thumb/useSliderThumb.ts
index 9171896545..b0e8c1042a 100644
--- a/packages/react/src/slider/thumb/useSliderThumb.ts
+++ b/packages/react/src/slider/thumb/useSliderThumb.ts
@@ -4,9 +4,17 @@ import { formatNumber } from '../../utils/formatNumber';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { GenericHTMLProps } from '../../utils/types';
import { useForkRef } from '../../utils/useForkRef';
-import { useBaseUiId } from '../../utils/useBaseUiId';
import { visuallyHidden } from '../../utils/visuallyHidden';
+import {
+ ARROW_DOWN,
+ ARROW_UP,
+ ARROW_RIGHT,
+ ARROW_LEFT,
+ HOME,
+ END,
+} from '../../composite/composite';
import { useCompositeListItem } from '../../composite/list/useCompositeListItem';
+import { useDirection } from '../../direction-provider/DirectionContext';
import { useFieldControlValidation } from '../../field/control/useFieldControlValidation';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
import { getSliderValue } from '../utils/getSliderValue';
@@ -52,14 +60,13 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-valuetext': ariaValuetext,
- changeValue,
- direction,
+ handleInputChange,
disabled,
format,
- getAriaLabel,
- getAriaValueText,
- id: idParam,
- inputId: inputIdParam,
+ getAriaLabel = null,
+ getAriaValueText = null,
+ id: thumbId,
+ inputId,
largeStep,
max,
min,
@@ -69,10 +76,11 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
percentageValues,
rootRef: externalRef,
step,
- tabIndex,
+ tabIndex: externalTabIndex,
values: sliderValues,
} = parameters;
+ const direction = useDirection();
const { setTouched } = useFieldRootContext();
const {
getInputValidationProps,
@@ -80,14 +88,10 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
commitValidation,
} = useFieldControlValidation();
- const thumbId = useBaseUiId(idParam);
-
const thumbRef = React.useRef(null);
const inputRef = React.useRef(null);
const mergedInputRef = useForkRef(inputRef, inputValidationRef);
- const inputId = useBaseUiId(inputIdParam);
-
const thumbMetadata = React.useMemo(
() => ({
inputId,
@@ -140,24 +144,17 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
}
setTouched(true);
commitValidation(
- getSliderValue({
- valueInput: thumbValue,
- min,
- max,
- index,
- range: sliderValues.length > 1,
- values: sliderValues,
- }),
+ getSliderValue(thumbValue, index, min, max, sliderValues.length > 1, sliderValues),
);
},
onKeyDown(event: React.KeyboardEvent) {
let newValue = null;
const isRange = sliderValues.length > 1;
switch (event.key) {
- case 'ArrowUp':
+ case ARROW_UP:
newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, 1, min, max);
break;
- case 'ArrowRight':
+ case ARROW_RIGHT:
newValue = getNewValue(
thumbValue,
event.shiftKey ? largeStep : step,
@@ -166,10 +163,10 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
max,
);
break;
- case 'ArrowDown':
+ case ARROW_DOWN:
newValue = getNewValue(thumbValue, event.shiftKey ? largeStep : step, -1, min, max);
break;
- case 'ArrowLeft':
+ case ARROW_LEFT:
newValue = getNewValue(
thumbValue,
event.shiftKey ? largeStep : step,
@@ -184,7 +181,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
case 'PageDown':
newValue = getNewValue(thumbValue, largeStep, -1, min, max);
break;
- case 'End':
+ case END:
newValue = max;
if (isRange) {
@@ -193,7 +190,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
: max;
}
break;
- case 'Home':
+ case HOME:
newValue = min;
if (isRange) {
@@ -207,22 +204,21 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
}
if (newValue !== null) {
- changeValue(newValue, index, event);
+ handleInputChange(newValue, index, event);
event.preventDefault();
}
},
ref: mergedThumbRef,
- style: {
- ...getThumbStyle(),
- },
- tabIndex: tabIndex ?? (disabled ? undefined : 0),
+ style: getThumbStyle(),
+ tabIndex: externalTabIndex ?? (disabled ? undefined : 0),
});
},
[
- changeValue,
commitValidation,
disabled,
+ externalTabIndex,
getThumbStyle,
+ handleInputChange,
index,
isRtl,
largeStep,
@@ -233,7 +229,6 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
setTouched,
sliderValues,
step,
- tabIndex,
thumbId,
thumbValue,
],
@@ -247,30 +242,29 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
}
return mergeReactProps(getInputValidationProps(externalProps), {
- 'aria-label': getAriaLabel ? getAriaLabel(index) : ariaLabel,
+ 'aria-label': getAriaLabel != null ? getAriaLabel(index) : ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-orientation': orientation,
'aria-valuemax': max,
'aria-valuemin': min,
'aria-valuenow': thumbValue,
- 'aria-valuetext': getAriaValueText
- ? getAriaValueText(formatNumber(thumbValue, [], format), thumbValue, index)
- : (ariaValuetext ?? getDefaultAriaValueText(sliderValues, index, format)),
+ 'aria-valuetext':
+ getAriaValueText != null
+ ? getAriaValueText(formatNumber(thumbValue, [], format ?? undefined), thumbValue, index)
+ : ariaValuetext || getDefaultAriaValueText(sliderValues, index, format ?? undefined),
'data-index': index,
disabled,
id: inputId,
max,
min,
name,
- onChange(event: React.ChangeEvent) {
- // @ts-ignore
- changeValue(event.target.valueAsNumber, index, event);
+ onChange(event: React.ChangeEvent) {
+ handleInputChange(event.target.valueAsNumber, index, event);
},
ref: mergedInputRef,
step,
style: {
...visuallyHidden,
- direction: isRtl ? 'rtl' : 'ltr',
// So that VoiceOver's focus indicator matches the thumb's dimensions
width: '100%',
height: '100%',
@@ -285,7 +279,7 @@ export function useSliderThumb(parameters: useSliderThumb.Parameters): useSlider
ariaLabel,
ariaLabelledby,
ariaValuetext,
- changeValue,
+ handleInputChange,
disabled,
format,
getAriaLabel,
@@ -322,8 +316,7 @@ export namespace useSliderThumb {
useSliderRoot.ReturnValue,
| 'active'
| 'aria-labelledby'
- | 'changeValue'
- | 'direction'
+ | 'handleInputChange'
| 'largeStep'
| 'max'
| 'min'
@@ -332,27 +325,28 @@ export namespace useSliderThumb {
| 'orientation'
| 'percentageValues'
| 'step'
- | 'tabIndex'
| 'values'
> {
/**
* The label for the input element.
*/
- 'aria-label'?: string;
+ 'aria-label': string;
/**
* A string value that provides a user-friendly name for the current value of the slider.
*/
- 'aria-valuetext'?: string;
+ 'aria-valuetext': string;
/**
* Options to format the input value.
+ * @default null
*/
- format?: Intl.NumberFormatOptions;
+ format: Intl.NumberFormatOptions | null;
/**
* Accepts a function which returns a string value that provides a user-friendly name for the input associated with the thumb
* @param {number} index The index of the input
* @returns {string}
+ * @type {((index: number) => string) | null}
*/
- getAriaLabel?: (index: number) => string;
+ getAriaLabel?: ((index: number) => string) | null;
/**
* Accepts a function which returns a string value that provides a user-friendly name for the current value of the slider.
* This is important for screen reader users.
@@ -360,15 +354,21 @@ export namespace useSliderThumb {
* @param {number} value The thumb's numerical value.
* @param {number} index The thumb's index.
* @returns {string}
+ * @type {((formattedValue: string, value: number, index: number) => string) | null}
*/
- getAriaValueText?: (formattedValue: string, value: number, index: number) => string;
- id?: React.HTMLAttributes['id'];
- inputId?: React.HTMLAttributes['id'];
+ getAriaValueText: ((formattedValue: string, value: number, index: number) => string) | null;
+ id: string;
+ inputId: string;
disabled: boolean;
- onBlur?: React.FocusEventHandler;
- onFocus?: React.FocusEventHandler;
- onKeyDown?: React.KeyboardEventHandler;
+ onBlur: React.FocusEventHandler;
+ onFocus: React.FocusEventHandler;
+ onKeyDown: React.KeyboardEventHandler;
rootRef?: React.Ref;
+ /**
+ * Optional tab index attribute for the thumb components.
+ * @default null
+ */
+ tabIndex: number | null;
}
export interface ReturnValue {
diff --git a/packages/react/src/slider/track/SliderTrack.test.tsx b/packages/react/src/slider/track/SliderTrack.test.tsx
index e53b90b198..4219709da6 100644
--- a/packages/react/src/slider/track/SliderTrack.test.tsx
+++ b/packages/react/src/slider/track/SliderTrack.test.tsx
@@ -6,22 +6,23 @@ import { NOOP } from '../../utils/noop';
const testRootContext: SliderRootContext = {
active: -1,
- areValuesEqual: () => true,
- changeValue: NOOP,
- direction: 'ltr',
+ handleInputChange: NOOP,
dragging: false,
disabled: false,
- getFingerNewValue: () => ({
- newValue: 0,
- activeIndex: 0,
- newPercentageValue: 0,
+ getFingerState: () => ({
+ value: 0,
+ valueRescaled: 0,
+ percentageValues: [0],
+ thumbIndex: 0,
}),
- handleValueChange: NOOP,
+ setValue: NOOP,
largeStep: 10,
thumbMap: new Map(),
max: 100,
min: 0,
minStepsBetweenValues: 0,
+ name: '',
+ onValueCommitted: NOOP,
orientation: 'horizontal',
state: {
activeThumbIndex: -1,
@@ -41,9 +42,10 @@ const testRootContext: SliderRootContext = {
registerSliderControl: NOOP,
setActive: NOOP,
setDragging: NOOP,
+ setPercentageValues: NOOP,
setThumbMap: NOOP,
- setValueState: NOOP,
step: 1,
+ tabIndex: null,
thumbRefs: { current: [] },
values: [0],
};
diff --git a/packages/react/src/slider/utils/getSliderValue.ts b/packages/react/src/slider/utils/getSliderValue.ts
index efe7d2019f..f82ef09aa5 100644
--- a/packages/react/src/slider/utils/getSliderValue.ts
+++ b/packages/react/src/slider/utils/getSliderValue.ts
@@ -1,31 +1,25 @@
import { clamp } from '../../utils/clamp';
-import { setValueIndex } from './setValueIndex';
-
-interface GetSliderValueParameters {
- valueInput: number;
- index: number;
- min: number;
- max: number;
- range: boolean;
- values: readonly number[];
-}
-
-export function getSliderValue(params: GetSliderValueParameters) {
- const { valueInput, index, min, max, range, values } = params;
+import { replaceArrayItemAtIndex } from './replaceArrayItemAtIndex';
+export function getSliderValue(
+ valueInput: number,
+ index: number,
+ min: number,
+ max: number,
+ range: boolean,
+ values: readonly number[],
+) {
let newValue: number | number[] = valueInput;
newValue = clamp(newValue, min, max);
if (range) {
- // Bound the new value to the thumb's neighbours.
- newValue = clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity);
-
- newValue = setValueIndex({
+ newValue = replaceArrayItemAtIndex(
values,
- newValue,
index,
- });
+ // Bound the new value to the thumb's neighbours.
+ clamp(newValue, values[index - 1] || -Infinity, values[index + 1] || Infinity),
+ );
}
return newValue;
diff --git a/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts b/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts
new file mode 100644
index 0000000000..4864f9591e
--- /dev/null
+++ b/packages/react/src/slider/utils/replaceArrayItemAtIndex.ts
@@ -0,0 +1,7 @@
+import { asc } from './asc';
+
+export function replaceArrayItemAtIndex(array: readonly number[], index: number, newValue: number) {
+ const output = array.slice();
+ output[index] = newValue;
+ return output.sort(asc);
+}
diff --git a/packages/react/src/slider/utils.ts b/packages/react/src/slider/utils/roundValueToStep.ts
similarity index 86%
rename from packages/react/src/slider/utils.ts
rename to packages/react/src/slider/utils/roundValueToStep.ts
index ed473cffd4..741f662f75 100644
--- a/packages/react/src/slider/utils.ts
+++ b/packages/react/src/slider/utils/roundValueToStep.ts
@@ -11,10 +11,6 @@ function getDecimalPrecision(num: number) {
return decimalPart ? decimalPart.length : 0;
}
-export function percentToValue(percent: number, min: number, max: number) {
- return (max - min) * percent + min;
-}
-
export function roundValueToStep(value: number, step: number, min: number) {
const nearest = Math.round((value - min) / step) * step + min;
return Number(nearest.toFixed(getDecimalPrecision(step)));
diff --git a/packages/react/src/slider/utils/setValueIndex.ts b/packages/react/src/slider/utils/setValueIndex.ts
deleted file mode 100644
index 291e096495..0000000000
--- a/packages/react/src/slider/utils/setValueIndex.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { asc } from './asc';
-
-export function setValueIndex({
- values,
- newValue,
- index,
-}: {
- values: readonly number[];
- newValue: number;
- index: number;
-}) {
- const output = values.slice();
- output[index] = newValue;
- return output.sort(asc);
-}
diff --git a/packages/react/src/slider/value/SliderValue.test.tsx b/packages/react/src/slider/value/SliderValue.test.tsx
index cb340a21ea..3d6e62f2e0 100644
--- a/packages/react/src/slider/value/SliderValue.test.tsx
+++ b/packages/react/src/slider/value/SliderValue.test.tsx
@@ -8,22 +8,23 @@ import { NOOP } from '../../utils/noop';
const testRootContext: SliderRootContext = {
active: -1,
- areValuesEqual: () => true,
- changeValue: NOOP,
- direction: 'ltr',
+ handleInputChange: NOOP,
dragging: false,
disabled: false,
- getFingerNewValue: () => ({
- newValue: 0,
- activeIndex: 0,
- newPercentageValue: 0,
+ getFingerState: () => ({
+ value: 0,
+ valueRescaled: 0,
+ percentageValues: [0],
+ thumbIndex: 0,
}),
- handleValueChange: NOOP,
+ setValue: NOOP,
largeStep: 10,
thumbMap: new Map(),
max: 100,
min: 0,
minStepsBetweenValues: 0,
+ name: '',
+ onValueCommitted: NOOP,
orientation: 'horizontal',
state: {
activeThumbIndex: -1,
@@ -43,9 +44,10 @@ const testRootContext: SliderRootContext = {
registerSliderControl: NOOP,
setActive: NOOP,
setDragging: NOOP,
+ setPercentageValues: NOOP,
setThumbMap: NOOP,
- setValueState: NOOP,
step: 1,
+ tabIndex: null,
thumbRefs: { current: [] },
values: [0],
};
diff --git a/packages/react/src/slider/value/SliderValue.tsx b/packages/react/src/slider/value/SliderValue.tsx
index 7c596e455d..71514660c9 100644
--- a/packages/react/src/slider/value/SliderValue.tsx
+++ b/packages/react/src/slider/value/SliderValue.tsx
@@ -17,12 +17,13 @@ const SliderValue = React.forwardRef(function SliderValue(
props: SliderValue.Props,
forwardedRef: React.ForwardedRef,
) {
- const { render, className, children, ...otherProps } = props;
+ const { 'aria-live': ariaLive = 'off', render, className, children, ...otherProps } = props;
const { thumbMap, state, values, format } = useSliderRootContext();
const { getRootProps, formattedValues } = useSliderValue({
- format,
+ 'aria-live': ariaLive,
+ format: format ?? null,
thumbMap,
values,
});
@@ -55,6 +56,10 @@ const SliderValue = React.forwardRef(function SliderValue(
export namespace SliderValue {
export interface Props
extends Omit, 'children'> {
+ /**
+ * @default 'off'
+ */
+ 'aria-live'?: React.AriaAttributes['aria-live'];
children?:
| null
| ((formattedValues: readonly string[], values: readonly number[]) => React.ReactNode);
@@ -68,6 +73,10 @@ SliderValue.propTypes /* remove-proptypes */ = {
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @default 'off'
+ */
+ 'aria-live': PropTypes.oneOf(['assertive', 'off', 'polite']),
/**
* @ignore
*/
diff --git a/packages/react/src/slider/value/useSliderValue.ts b/packages/react/src/slider/value/useSliderValue.ts
index 2c1b71f051..f0385b617f 100644
--- a/packages/react/src/slider/value/useSliderValue.ts
+++ b/packages/react/src/slider/value/useSliderValue.ts
@@ -5,7 +5,7 @@ import { mergeReactProps } from '../../utils/mergeReactProps';
import type { useSliderRoot } from '../root/useSliderRoot';
export function useSliderValue(parameters: useSliderValue.Parameters): useSliderValue.ReturnValue {
- const { 'aria-live': ariaLive = 'off', format: formatParam, thumbMap, values } = parameters;
+ const { 'aria-live': ariaLive, format: formatParam, thumbMap, values } = parameters;
const outputFor = React.useMemo(() => {
let htmlFor = '';
@@ -20,9 +20,7 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider
const formattedValues = React.useMemo(() => {
const arr = [];
for (let i = 0; i < values.length; i += 1) {
- arr.push(
- formatNumber(values[i], [], Array.isArray(formatParam) ? formatParam[i] : formatParam),
- );
+ arr.push(formatNumber(values[i], [], formatParam ?? undefined));
}
return arr;
}, [formatParam, values]);
@@ -50,11 +48,12 @@ export function useSliderValue(parameters: useSliderValue.Parameters): useSlider
export namespace useSliderValue {
export interface Parameters extends Pick {
- 'aria-live'?: React.AriaAttributes['aria-live'];
+ 'aria-live': React.AriaAttributes['aria-live'];
/**
* Options to format the input value.
+ * @default null
*/
- format?: Intl.NumberFormatOptions | Intl.NumberFormatOptions[];
+ format: Intl.NumberFormatOptions | null;
}
export interface ReturnValue {
From 2f1205abb8fb41b331f0e45476f8019a780d75ab Mon Sep 17 00:00:00 2001
From: Hakimuddin
Date: Tue, 7 Jan 2025 14:21:57 +0530
Subject: [PATCH 2/3] [Select, Menu] Handle pseudo-element bounds in mouseup
detection (#1250)
---
.../react/src/menu/trigger/useMenuTrigger.ts | 24 +++++++-----
.../src/select/trigger/useSelectTrigger.ts | 25 +++++++-----
.../react/src/utils/getPseudoElementBounds.ts | 39 +++++++++++++++++++
3 files changed, 70 insertions(+), 18 deletions(-)
create mode 100644 packages/react/src/utils/getPseudoElementBounds.ts
diff --git a/packages/react/src/menu/trigger/useMenuTrigger.ts b/packages/react/src/menu/trigger/useMenuTrigger.ts
index 40f7d9cc31..b51f0bcdac 100644
--- a/packages/react/src/menu/trigger/useMenuTrigger.ts
+++ b/packages/react/src/menu/trigger/useMenuTrigger.ts
@@ -6,8 +6,11 @@ import { useForkRef } from '../../utils/useForkRef';
import { GenericHTMLProps } from '../../utils/types';
import { mergeReactProps } from '../../utils/mergeReactProps';
import { ownerDocument } from '../../utils/owner';
+import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds';
export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTrigger.ReturnValue {
+ const BOUNDARY_OFFSET = 2;
+
const {
disabled = false,
rootRef: externalRef,
@@ -71,18 +74,21 @@ export function useMenuTrigger(parameters: useMenuTrigger.Parameters): useMenuTr
const mouseUpTarget = mouseEvent.target as Element | null;
- const triggerRect = triggerRef.current.getBoundingClientRect();
+ if (
+ contains(triggerRef.current, mouseUpTarget) ||
+ contains(positionerRef.current, mouseUpTarget) ||
+ mouseUpTarget === triggerRef.current
+ ) {
+ return;
+ }
- const isInsideTrigger =
- mouseEvent.clientX >= triggerRect.left &&
- mouseEvent.clientX <= triggerRect.right &&
- mouseEvent.clientY >= triggerRect.top &&
- mouseEvent.clientY <= triggerRect.bottom;
+ const bounds = getPseudoElementBounds(triggerRef.current);
if (
- isInsideTrigger ||
- contains(positionerRef.current, mouseUpTarget) ||
- contains(triggerRef.current, mouseUpTarget)
+ mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET &&
+ mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET &&
+ mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET &&
+ mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET
) {
return;
}
diff --git a/packages/react/src/select/trigger/useSelectTrigger.ts b/packages/react/src/select/trigger/useSelectTrigger.ts
index f6521ed4a7..a41b50017b 100644
--- a/packages/react/src/select/trigger/useSelectTrigger.ts
+++ b/packages/react/src/select/trigger/useSelectTrigger.ts
@@ -7,10 +7,13 @@ import { useForkRef } from '../../utils/useForkRef';
import { useSelectRootContext } from '../root/SelectRootContext';
import { ownerDocument } from '../../utils/owner';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
+import { getPseudoElementBounds } from '../../utils/getPseudoElementBounds';
export function useSelectTrigger(
parameters: useSelectTrigger.Parameters,
): useSelectTrigger.ReturnValue {
+ const BOUNDARY_OFFSET = 2;
+
const { disabled = false, rootRef: externalRef } = parameters;
const {
@@ -107,18 +110,22 @@ export function useSelectTrigger(
const mouseUpTarget = mouseEvent.target as Element | null;
- const triggerRect = triggerRef.current.getBoundingClientRect();
+ // Early return if clicked on trigger element or its children
+ if (
+ contains(triggerRef.current, mouseUpTarget) ||
+ contains(positionerElement, mouseUpTarget) ||
+ mouseUpTarget === triggerRef.current
+ ) {
+ return;
+ }
- const isInsideTrigger =
- mouseEvent.clientX >= triggerRect.left &&
- mouseEvent.clientX <= triggerRect.right &&
- mouseEvent.clientY >= triggerRect.top &&
- mouseEvent.clientY <= triggerRect.bottom;
+ const bounds = getPseudoElementBounds(triggerRef.current);
if (
- isInsideTrigger ||
- contains(positionerElement, mouseUpTarget) ||
- contains(triggerRef.current, mouseUpTarget)
+ mouseEvent.clientX >= bounds.left - BOUNDARY_OFFSET &&
+ mouseEvent.clientX <= bounds.right + BOUNDARY_OFFSET &&
+ mouseEvent.clientY >= bounds.top - BOUNDARY_OFFSET &&
+ mouseEvent.clientY <= bounds.bottom + BOUNDARY_OFFSET
) {
return;
}
diff --git a/packages/react/src/utils/getPseudoElementBounds.ts b/packages/react/src/utils/getPseudoElementBounds.ts
new file mode 100644
index 0000000000..5838dae2bb
--- /dev/null
+++ b/packages/react/src/utils/getPseudoElementBounds.ts
@@ -0,0 +1,39 @@
+interface ElementBounds {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+}
+
+export function getPseudoElementBounds(element: HTMLElement): ElementBounds {
+ const elementRect = element.getBoundingClientRect();
+ const beforeStyles = window.getComputedStyle(element, '::before');
+ const afterStyles = window.getComputedStyle(element, '::after');
+
+ const hasPseudoElements = beforeStyles.content !== 'none' || afterStyles.content !== 'none';
+
+ if (!hasPseudoElements) {
+ return elementRect;
+ }
+
+ // Get dimensions of pseudo-elements
+ const beforeWidth = parseFloat(beforeStyles.width) || 0;
+ const beforeHeight = parseFloat(beforeStyles.height) || 0;
+ const afterWidth = parseFloat(afterStyles.width) || 0;
+ const afterHeight = parseFloat(afterStyles.height) || 0;
+
+ // Calculate max dimensions including pseudo-elements
+ const totalWidth = Math.max(elementRect.width, beforeWidth, afterWidth);
+ const totalHeight = Math.max(elementRect.height, beforeHeight, afterHeight);
+
+ // Calculate the differences to extend the bounds
+ const widthDiff = totalWidth - elementRect.width;
+ const heightDiff = totalHeight - elementRect.height;
+
+ return {
+ left: elementRect.left - widthDiff / 2,
+ right: elementRect.right + widthDiff / 2,
+ top: elementRect.top - heightDiff / 2,
+ bottom: elementRect.bottom + heightDiff / 2,
+ };
+}
From 1908c944af478b2f99aa70bf9440aa712eca758f Mon Sep 17 00:00:00 2001
From: atomiks
Date: Tue, 7 Jan 2025 20:43:53 +1100
Subject: [PATCH 3/3] [popups] Require `Portal` part (#1222)
---
.../generated/alert-dialog-backdrop.json | 5 -
.../generated/alert-dialog-popup.json | 5 -
.../generated/alert-dialog-portal.json | 17 +
docs/reference/generated/dialog-backdrop.json | 5 -
docs/reference/generated/dialog-popup.json | 5 -
docs/reference/generated/dialog-portal.json | 17 +
docs/reference/generated/menu-backdrop.json | 5 -
.../{portal.json => menu-portal.json} | 4 +-
docs/reference/generated/menu-positioner.json | 5 -
.../reference/generated/popover-backdrop.json | 5 -
docs/reference/generated/popover-portal.json | 17 +
.../generated/popover-positioner.json | 5 -
.../generated/preview-card-backdrop.json | 5 -
.../generated/preview-card-portal.json | 17 +
.../generated/preview-card-positioner.json | 5 -
docs/reference/generated/select-backdrop.json | 5 -
docs/reference/generated/select-portal.json | 2 +-
docs/reference/generated/tooltip-portal.json | 17 +
.../generated/tooltip-positioner.json | 5 -
.../experiments/anchor-positioning.tsx | 1 +
.../experiments/anchor-side-animations.tsx | 16 +-
docs/src/app/(private)/experiments/dialog.tsx | 107 +++---
.../(private)/experiments/menu-anchor-el.tsx | 32 +-
.../(private)/experiments/menu-anchor-ref.tsx | 32 +-
.../app/(private)/experiments/menu-nested.tsx | 142 ++++---
.../app/(private)/experiments/menu-rtl.tsx | 142 ++++---
.../app/(private)/experiments/modality.tsx | 70 ++--
.../experiments/popup-transform-origin.tsx | 20 +-
.../experiments/popups-in-popups.tsx | 170 +++++----
docs/src/app/(private)/experiments/rtl.tsx | 346 ++++++++++--------
.../app/(private)/experiments/select-perf.tsx | 66 ++--
.../src/app/(private)/experiments/tooltip.tsx | 160 ++++----
.../dialog/demos/hero/css-modules/index.tsx | 2 +-
.../backdrop/AlertDialogBackdrop.tsx | 17 +-
.../close/AlertDialogClose.test.tsx | 4 +-
.../AlertDialogDescription.test.tsx | 4 +-
.../react/src/alert-dialog/index.parts.ts | 2 +-
.../popup/AlertDialogPopup.test.tsx | 64 ++--
.../alert-dialog/popup/AlertDialogPopup.tsx | 19 +-
.../alert-dialog/portal/AlertDialogPortal.tsx | 67 ++++
.../portal/AlertDialogPortalContext.ts | 11 +
.../root/AlertDialogRoot.test.tsx | 32 +-
.../src/alert-dialog/root/AlertDialogRoot.tsx | 3 +-
.../title/AlertDialogTitle.test.tsx | 4 +-
.../src/dialog/backdrop/DialogBackdrop.tsx | 17 +-
.../src/dialog/close/DialogClose.test.tsx | 4 +-
.../description/DialogDescription.test.tsx | 4 +-
packages/react/src/dialog/index.parts.ts | 2 +-
.../src/dialog/popup/DialogPopup.test.tsx | 60 +--
.../react/src/dialog/popup/DialogPopup.tsx | 19 +-
.../react/src/dialog/portal/DialogPortal.tsx | 67 ++++
.../src/dialog/portal/DialogPortalContext.ts | 11 +
.../react/src/dialog/root/DialogRoot.test.tsx | 81 ++--
packages/react/src/dialog/root/DialogRoot.tsx | 4 +-
.../src/dialog/title/DialogTitle.test.tsx | 4 +-
.../react/src/field/root/FieldRoot.test.tsx | 28 +-
.../react/src/menu/arrow/MenuArrow.test.tsx | 8 +-
.../react/src/menu/backdrop/MenuBackdrop.tsx | 20 +-
.../MenuCheckboxItemIndicator.test.tsx | 54 +--
.../checkbox-item/MenuCheckboxItem.test.tsx | 130 ++++---
.../menu/group-label/MenuGroupLabel.test.tsx | 48 +--
packages/react/src/menu/index.parts.ts | 2 +-
.../react/src/menu/item/MenuItem.test.tsx | 74 ++--
.../react/src/menu/popup/MenuPopup.test.tsx | 4 +-
.../Portal.tsx => menu/portal/MenuPortal.tsx} | 37 +-
.../src/menu/portal/MenuPortalContext.ts | 11 +
.../menu/positioner/MenuPositioner.test.tsx | 136 ++++---
.../src/menu/positioner/MenuPositioner.tsx | 14 +-
.../src/menu/positioner/useMenuPositioner.ts | 15 +-
.../MenuRadioItemIndicator.test.tsx | 78 ++--
.../menu/radio-item/MenuRadioItem.test.tsx | 166 +++++----
.../react/src/menu/root/MenuRoot.test.tsx | 330 +++++++++--------
packages/react/src/menu/root/MenuRoot.tsx | 11 +-
.../src/menu/trigger/MenuTrigger.test.tsx | 28 +-
.../src/popover/arrow/PopoverArrow.test.tsx | 8 +-
.../src/popover/backdrop/PopoverBackdrop.tsx | 20 +-
.../src/popover/close/PopoverClose.test.tsx | 22 +-
.../description/PopoverDescription.test.tsx | 20 +-
packages/react/src/popover/index.parts.ts | 2 +-
.../src/popover/popup/PopoverPopup.test.tsx | 86 +++--
.../src/popover/portal/PopoverPortal.tsx | 67 ++++
.../popover/portal/PopoverPortalContext.ts | 11 +
.../positioner/PopoverPositioner.test.tsx | 14 +-
.../popover/positioner/PopoverPositioner.tsx | 15 +-
.../positioner/usePopoverPositioner.tsx | 15 +-
.../src/popover/root/PopoverRoot.test.tsx | 150 +++++---
.../react/src/popover/root/PopoverRoot.tsx | 5 +-
.../src/popover/title/PopoverTitle.test.tsx | 20 +-
packages/react/src/portal/PortalContext.ts | 17 -
.../arrow/PreviewCardArrow.test.tsx | 8 +-
.../backdrop/PreviewCardBackdrop.tsx | 20 +-
.../react/src/preview-card/index.parts.ts | 2 +-
.../popup/PreviewCardPopup.test.tsx | 12 +-
.../preview-card/portal/PreviewCardPortal.tsx | 67 ++++
.../portal/PreviewCardPortalContext.ts | 11 +
.../positioner/PreviewCardPositioner.test.tsx | 6 +-
.../positioner/PreviewCardPositioner.tsx | 15 +-
.../positioner/usePreviewCardPositioner.ts | 15 +-
.../root/PreviewCardRoot.test.tsx | 138 ++++---
.../src/preview-card/root/PreviewCardRoot.tsx | 3 +-
.../src/select/backdrop/SelectBackdrop.tsx | 20 +-
.../react/src/select/item/SelectItem.test.tsx | 92 +++--
packages/react/src/select/item/SelectItem.tsx | 28 +-
.../src/select/popup/SelectPopup.test.tsx | 4 +-
.../react/src/select/portal/SelectPortal.tsx | 21 +-
.../src/select/portal/SelectPortalContext.ts | 11 +
.../positioner/SelectPositioner.test.tsx | 6 +-
.../select/positioner/useSelectPositioner.ts | 5 -
.../react/src/select/root/SelectRoot.test.tsx | 140 ++++---
packages/react/src/select/root/SelectRoot.tsx | 5 +-
.../src/select/root/SelectRootContext.ts | 1 +
.../react/src/select/root/useSelectRoot.ts | 39 +-
.../src/tooltip/arrow/TooltipArrow.test.tsx | 8 +-
packages/react/src/tooltip/index.parts.ts | 2 +-
.../src/tooltip/popup/TooltipPopup.test.tsx | 12 +-
.../src/tooltip/portal/TooltipPortal.tsx | 67 ++++
.../tooltip/portal/TooltipPortalContext.ts | 11 +
.../positioner/TooltipPositioner.test.tsx | 6 +-
.../tooltip/positioner/TooltipPositioner.tsx | 15 +-
.../positioner/useTooltipPositioner.ts | 15 +-
.../tooltip/provider/TooltipProvider.test.tsx | 16 +-
.../src/tooltip/root/TooltipRoot.test.tsx | 112 +++---
.../react/src/tooltip/root/TooltipRoot.tsx | 5 +-
.../react/src/utils/useAnchorPositioning.ts | 2 +-
124 files changed, 2677 insertions(+), 1886 deletions(-)
create mode 100644 docs/reference/generated/alert-dialog-portal.json
create mode 100644 docs/reference/generated/dialog-portal.json
rename docs/reference/generated/{portal.json => menu-portal.json} (82%)
create mode 100644 docs/reference/generated/popover-portal.json
create mode 100644 docs/reference/generated/preview-card-portal.json
create mode 100644 docs/reference/generated/tooltip-portal.json
create mode 100644 packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
create mode 100644 packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts
create mode 100644 packages/react/src/dialog/portal/DialogPortal.tsx
create mode 100644 packages/react/src/dialog/portal/DialogPortalContext.ts
rename packages/react/src/{portal/Portal.tsx => menu/portal/MenuPortal.tsx} (66%)
create mode 100644 packages/react/src/menu/portal/MenuPortalContext.ts
create mode 100644 packages/react/src/popover/portal/PopoverPortal.tsx
create mode 100644 packages/react/src/popover/portal/PopoverPortalContext.ts
delete mode 100644 packages/react/src/portal/PortalContext.ts
create mode 100644 packages/react/src/preview-card/portal/PreviewCardPortal.tsx
create mode 100644 packages/react/src/preview-card/portal/PreviewCardPortalContext.ts
create mode 100644 packages/react/src/select/portal/SelectPortalContext.ts
create mode 100644 packages/react/src/tooltip/portal/TooltipPortal.tsx
create mode 100644 packages/react/src/tooltip/portal/TooltipPortalContext.ts
diff --git a/docs/reference/generated/alert-dialog-backdrop.json b/docs/reference/generated/alert-dialog-backdrop.json
index 0f6c03789b..51d2ad0a79 100644
--- a/docs/reference/generated/alert-dialog-backdrop.json
+++ b/docs/reference/generated/alert-dialog-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the element in the DOM while the alert dialog is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/alert-dialog-popup.json b/docs/reference/generated/alert-dialog-popup.json
index 4abf067b82..b8a174b76f 100644
--- a/docs/reference/generated/alert-dialog-popup.json
+++ b/docs/reference/generated/alert-dialog-popup.json
@@ -14,11 +14,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the element in the DOM while the alert dialog is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/alert-dialog-portal.json b/docs/reference/generated/alert-dialog-portal.json
new file mode 100644
index 0000000000..0422346f24
--- /dev/null
+++ b/docs/reference/generated/alert-dialog-portal.json
@@ -0,0 +1,17 @@
+{
+ "name": "AlertDialogPortal",
+ "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
+ "props": {
+ "container": {
+ "type": "React.Ref | HTMLElement | null",
+ "description": "A parent element to render the portal element into."
+ },
+ "keepMounted": {
+ "type": "boolean",
+ "default": "false",
+ "description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
+ }
+ },
+ "dataAttributes": {},
+ "cssVariables": {}
+}
diff --git a/docs/reference/generated/dialog-backdrop.json b/docs/reference/generated/dialog-backdrop.json
index 801ca1fab0..db2b4ba5b7 100644
--- a/docs/reference/generated/dialog-backdrop.json
+++ b/docs/reference/generated/dialog-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the dialog is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/dialog-popup.json b/docs/reference/generated/dialog-popup.json
index 1a0128d58f..186de3d19c 100644
--- a/docs/reference/generated/dialog-popup.json
+++ b/docs/reference/generated/dialog-popup.json
@@ -14,11 +14,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the dialog is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/dialog-portal.json b/docs/reference/generated/dialog-portal.json
new file mode 100644
index 0000000000..87565ef214
--- /dev/null
+++ b/docs/reference/generated/dialog-portal.json
@@ -0,0 +1,17 @@
+{
+ "name": "DialogPortal",
+ "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
+ "props": {
+ "container": {
+ "type": "React.Ref | HTMLElement | null",
+ "description": "A parent element to render the portal element into."
+ },
+ "keepMounted": {
+ "type": "boolean",
+ "default": "false",
+ "description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
+ }
+ },
+ "dataAttributes": {},
+ "cssVariables": {}
+}
diff --git a/docs/reference/generated/menu-backdrop.json b/docs/reference/generated/menu-backdrop.json
index 58832ed4bc..de7938fded 100644
--- a/docs/reference/generated/menu-backdrop.json
+++ b/docs/reference/generated/menu-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the menu is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/portal.json b/docs/reference/generated/menu-portal.json
similarity index 82%
rename from docs/reference/generated/portal.json
rename to docs/reference/generated/menu-portal.json
index b0e0ba31d5..5a7bcc3858 100644
--- a/docs/reference/generated/portal.json
+++ b/docs/reference/generated/menu-portal.json
@@ -1,10 +1,10 @@
{
- "name": "Portal",
+ "name": "MenuPortal",
"description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
"props": {
"container": {
"type": "React.Ref | HTMLElement | null",
- "description": "A parent element to render the portal into."
+ "description": "A parent element to render the portal element into."
},
"keepMounted": {
"type": "boolean",
diff --git a/docs/reference/generated/menu-positioner.json b/docs/reference/generated/menu-positioner.json
index dc8fc9f0ac..105a5f811a 100644
--- a/docs/reference/generated/menu-positioner.json
+++ b/docs/reference/generated/menu-positioner.json
@@ -53,11 +53,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the menu is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/popover-backdrop.json b/docs/reference/generated/popover-backdrop.json
index d3e69417e7..4b81ad5446 100644
--- a/docs/reference/generated/popover-backdrop.json
+++ b/docs/reference/generated/popover-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the popover is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/popover-portal.json b/docs/reference/generated/popover-portal.json
new file mode 100644
index 0000000000..4a4554c421
--- /dev/null
+++ b/docs/reference/generated/popover-portal.json
@@ -0,0 +1,17 @@
+{
+ "name": "PopoverPortal",
+ "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
+ "props": {
+ "container": {
+ "type": "React.Ref | HTMLElement | null",
+ "description": "A parent element to render the portal element into."
+ },
+ "keepMounted": {
+ "type": "boolean",
+ "default": "false",
+ "description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
+ }
+ },
+ "dataAttributes": {},
+ "cssVariables": {}
+}
diff --git a/docs/reference/generated/popover-positioner.json b/docs/reference/generated/popover-positioner.json
index 9a155f18dd..5d7d915bcc 100644
--- a/docs/reference/generated/popover-positioner.json
+++ b/docs/reference/generated/popover-positioner.json
@@ -55,11 +55,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the popover is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/preview-card-backdrop.json b/docs/reference/generated/preview-card-backdrop.json
index 667211ad56..815bae8001 100644
--- a/docs/reference/generated/preview-card-backdrop.json
+++ b/docs/reference/generated/preview-card-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the preview card is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/preview-card-portal.json b/docs/reference/generated/preview-card-portal.json
new file mode 100644
index 0000000000..34c82e1b85
--- /dev/null
+++ b/docs/reference/generated/preview-card-portal.json
@@ -0,0 +1,17 @@
+{
+ "name": "PreviewCardPortal",
+ "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
+ "props": {
+ "container": {
+ "type": "React.Ref | HTMLElement | null",
+ "description": "A parent element to render the portal element into."
+ },
+ "keepMounted": {
+ "type": "boolean",
+ "default": "false",
+ "description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
+ }
+ },
+ "dataAttributes": {},
+ "cssVariables": {}
+}
diff --git a/docs/reference/generated/preview-card-positioner.json b/docs/reference/generated/preview-card-positioner.json
index 5fea11a83e..8ec325c32e 100644
--- a/docs/reference/generated/preview-card-positioner.json
+++ b/docs/reference/generated/preview-card-positioner.json
@@ -55,11 +55,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the preview card is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/select-backdrop.json b/docs/reference/generated/select-backdrop.json
index bd55c2bf52..2dab04eafd 100644
--- a/docs/reference/generated/select-backdrop.json
+++ b/docs/reference/generated/select-backdrop.json
@@ -6,11 +6,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the select menu is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/reference/generated/select-portal.json b/docs/reference/generated/select-portal.json
index d568bb6286..72a3729bc6 100644
--- a/docs/reference/generated/select-portal.json
+++ b/docs/reference/generated/select-portal.json
@@ -4,7 +4,7 @@
"props": {
"container": {
"type": "React.Ref | HTMLElement | null",
- "description": "A parent element to render the portal into."
+ "description": "A parent element to render the portal element into."
}
},
"dataAttributes": {},
diff --git a/docs/reference/generated/tooltip-portal.json b/docs/reference/generated/tooltip-portal.json
new file mode 100644
index 0000000000..41be076137
--- /dev/null
+++ b/docs/reference/generated/tooltip-portal.json
@@ -0,0 +1,17 @@
+{
+ "name": "TooltipPortal",
+ "description": "A portal element that moves the popup to a different part of the DOM.\nBy default, the portal element is appended to ``.",
+ "props": {
+ "container": {
+ "type": "React.Ref | HTMLElement | null",
+ "description": "A parent element to render the portal element into."
+ },
+ "keepMounted": {
+ "type": "boolean",
+ "default": "false",
+ "description": "Whether to keep the portal mounted in the DOM while the popup is hidden."
+ }
+ },
+ "dataAttributes": {},
+ "cssVariables": {}
+}
diff --git a/docs/reference/generated/tooltip-positioner.json b/docs/reference/generated/tooltip-positioner.json
index 538059bb6a..5ca373df6f 100644
--- a/docs/reference/generated/tooltip-positioner.json
+++ b/docs/reference/generated/tooltip-positioner.json
@@ -55,11 +55,6 @@
"type": "string | (state) => string",
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state."
},
- "keepMounted": {
- "type": "boolean",
- "default": "false",
- "description": "Whether to keep the HTML element in the DOM while the tooltip is hidden."
- },
"render": {
"type": "React.ReactElement | (props, state) => React.ReactElement",
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render."
diff --git a/docs/src/app/(private)/experiments/anchor-positioning.tsx b/docs/src/app/(private)/experiments/anchor-positioning.tsx
index 8c3a4107a3..bad594ce66 100644
--- a/docs/src/app/(private)/experiments/anchor-positioning.tsx
+++ b/docs/src/app/(private)/experiments/anchor-positioning.tsx
@@ -49,6 +49,7 @@ export default function AnchorPositioning() {
arrowPadding,
trackAnchor,
mounted: true,
+ keepMounted: true,
});
const handleInitialScroll = React.useCallback((node: HTMLDivElement | null) => {
diff --git a/docs/src/app/(private)/experiments/anchor-side-animations.tsx b/docs/src/app/(private)/experiments/anchor-side-animations.tsx
index eecc703447..678d44e0c1 100644
--- a/docs/src/app/(private)/experiments/anchor-side-animations.tsx
+++ b/docs/src/app/(private)/experiments/anchor-side-animations.tsx
@@ -13,16 +13,20 @@ export default function AnchorSideAnimations() {
transition
-
-
-
+
+
+
+
+
animation
-
-
-
+
+
+
+
+
);
diff --git a/docs/src/app/(private)/experiments/dialog.tsx b/docs/src/app/(private)/experiments/dialog.tsx
index aef9c97950..1573d4d1c7 100644
--- a/docs/src/app/(private)/experiments/dialog.tsx
+++ b/docs/src/app/(private)/experiments/dialog.tsx
@@ -46,15 +46,17 @@ function renderContent(
Open nested
-
- {renderContent(
- `Nested dialog ${NESTED_DIALOGS + 1 - includeNested}`,
- includeNested - 1,
- nestedClassName,
- modal,
- dismissible,
- )}
-
+
+
+ {renderContent(
+ `Nested dialog ${NESTED_DIALOGS + 1 - includeNested}`,
+ includeNested - 1,
+ nestedClassName,
+ modal,
+ dismissible,
+ )}
+
+
) : null}
@@ -72,23 +74,20 @@ function CssTransitionDialogDemo({ keepMounted, modal, dismissible }: DemoProps)
Open with CSS transition
-
-
-
- {renderContent(
- 'Dialog with CSS transitions',
- NESTED_DIALOGS,
- classes.withTransitions,
- modal,
- dismissible,
- )}
-
+
+
+
+ {renderContent(
+ 'Dialog with CSS transitions',
+ NESTED_DIALOGS,
+ classes.withTransitions,
+ modal,
+ dismissible,
+ )}
+
+
);
@@ -102,23 +101,20 @@ function CssAnimationDialogDemo({ keepMounted, modal, dismissible }: DemoProps)
Open with CSS animation
-
-
-
- {renderContent(
- 'Dialog with CSS animations',
- NESTED_DIALOGS,
- classes.withAnimations,
- modal,
- dismissible,
- )}
-
+
+
+
+ {renderContent(
+ 'Dialog with CSS animations',
+ NESTED_DIALOGS,
+ classes.withAnimations,
+ modal,
+ dismissible,
+ )}
+
+
);
@@ -141,18 +137,19 @@ function ReactSpringDialogDemo({ keepMounted, modal, dismissible }: DemoProps) {
/>
-
- {renderContent(
- 'Dialog with ReactSpring transitions',
- 3,
- classes.withReactSpringTransition,
- modal,
- dismissible,
- )}
-
+
+
+ {renderContent(
+ 'Dialog with ReactSpring transitions',
+ 3,
+ classes.withReactSpringTransition,
+ modal,
+ dismissible,
+ )}
+
+
diff --git a/docs/src/app/(private)/experiments/menu-anchor-el.tsx b/docs/src/app/(private)/experiments/menu-anchor-el.tsx
index 01bf4b83fe..39479cb555 100644
--- a/docs/src/app/(private)/experiments/menu-anchor-el.tsx
+++ b/docs/src/app/(private)/experiments/menu-anchor-el.tsx
@@ -14,21 +14,23 @@ export default function Page() {
Element passed to anchor
Trigger
-
-
-
- One
-
-
- Two
-
-
-
+
+
+
+
+ One
+
+
+ Two
+
+
+
+
Ref passed to anchor
Trigger
-
-
-
- One
-
-
- Two
-
-
-
+
+
+
+
+ One
+
+
+ Two
+
+
+
+
Text color
-
-
-
- Black
-
-
- Dark grey
-
-
- Accent
-
-
-
+
+
+
+
+ Black
+
+
+ Dark grey
+
+
+ Accent
+
+
+
+
Style
-
-
-
- Heading
-
-
-
+
+
+
+ Heading
+
+
- Level 1
-
-
+
+ Level 1
+
+
+ Level 2
+
+
+ Level 3
+
+
+
+
+
+
+ Paragraph
+
+
+ List
+
+
- Level 2
-
-
- Level 3
-
-
-
-
-
- Paragraph
-
-
- List
-
-
-
- Ordered
-
-
- Unordered
-
-
-
-
-
-
+
+
+ Ordered
+
+
+ Unordered
+
+
+
+
+
+
+
+
diff --git a/docs/src/app/(private)/experiments/menu-rtl.tsx b/docs/src/app/(private)/experiments/menu-rtl.tsx
index 1aee9f2a55..d1c2b1e2a3 100644
--- a/docs/src/app/(private)/experiments/menu-rtl.tsx
+++ b/docs/src/app/(private)/experiments/menu-rtl.tsx
@@ -16,44 +16,38 @@ export default function RtlPopover() {
-
-
-
-
-
- Notifications
-
- You are all caught up. Good job!
-
-
-
+
+
+
+
+
+
+ Notifications
+
+ You are all caught up. Good job!
+
+
+
+
-
-
-
-
-
- Notifications
-
- You are all caught up. Good job!
-
-
-
+
+
+
+
+
+
+ Notifications
+
+ You are all caught up. Good job!
+
+
+
+
@@ -62,52 +56,56 @@ export default function RtlPopover() {
Song
-
-
-
-
-
- Add to Library
- Add to Playlist
-
- Play Next
- Play Last
-
- Favorite
- Share
-
-
+
+
+
+
+
+
+ Add to Library
+ Add to Playlist
+
+ Play Next
+ Play Last
+
+ Favorite
+ Share
+
+
+
Song
-
-
-
-
-
- Add to Library
- Add to Playlist
-
- Play Next
- Play Last
-
- Favorite
- Share
-
-
+
+
+
+
+
+
+ Add to Library
+ Add to Playlist
+
+ Play Next
+ Play Last
+
+ Favorite
+ Share
+
+
+
diff --git a/docs/src/app/(private)/experiments/modality.tsx b/docs/src/app/(private)/experiments/modality.tsx
index 2ecb578fcc..2a528b609c 100644
--- a/docs/src/app/(private)/experiments/modality.tsx
+++ b/docs/src/app/(private)/experiments/modality.tsx
@@ -41,22 +41,24 @@ function SelectDemo({ modal, withBackdrop }: Props) {
{withBackdrop && } />}
- }>
-
-
- } />
- System font
-
-
- } />
- Arial
-
-
- } />
- Roboto
-
-
-
+
+ }>
+
+
+ } />
+ System font
+
+
+ } />
+ Arial
+
+
+ } />
+ Roboto
+
+
+
+
);
}
@@ -68,11 +70,15 @@ function MenuDemo({ modal, withBackdrop }: Props) {
{withBackdrop && } />}
- }>
-
- console.log('Log out clicked')}>Log out
-
-
+
+ }>
+
+ console.log('Log out clicked')}>
+ Log out
+
+
+
+
);
}
@@ -84,16 +90,18 @@ function DialogDemo({ modal, withBackdrop }: Props) {
{withBackdrop && } />}
-
- Subscribe
-
- Enter your email address to subscribe to our newsletter.
-
-
- Subscribe
- Cancel
-
-
+
+
+ Subscribe
+
+ Enter your email address to subscribe to our newsletter.
+
+
+ Subscribe
+ Cancel
+
+
+
);
}
diff --git a/docs/src/app/(private)/experiments/popup-transform-origin.tsx b/docs/src/app/(private)/experiments/popup-transform-origin.tsx
index 98b804e5d2..97731bc862 100644
--- a/docs/src/app/(private)/experiments/popup-transform-origin.tsx
+++ b/docs/src/app/(private)/experiments/popup-transform-origin.tsx
@@ -9,9 +9,11 @@ function Popover({ side }: { side: Side }) {
{side}
-
-
-
+
+
+
+
+
);
}
@@ -22,11 +24,13 @@ function PopoverWithArrow({ side }: { side: Side }) {
{side}
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/docs/src/app/(private)/experiments/popups-in-popups.tsx b/docs/src/app/(private)/experiments/popups-in-popups.tsx
index f8b7aad6d6..1d4383da86 100644
--- a/docs/src/app/(private)/experiments/popups-in-popups.tsx
+++ b/docs/src/app/(private)/experiments/popups-in-popups.tsx
@@ -108,89 +108,105 @@ function MenuDemo({ modal }: Props) {
Text color
- }
- >
-
-
- Black
-
-
- Dark grey
-
-
- Accent
-
-
-
+
+ }
+ >
+
+
+ Black
+
+
+ Dark grey
+
+
+ Accent
+
+
+
+
Style
- }
- >
-
-
- Heading
- }
- >
-
-
- Level 1
-
-
- Level 2
-
-
- Level 3
-
-
-
-
-
- Paragraph
-
-
- List
- }
- >
-
-
+ }
+ >
+
+
+ Heading
+
+ }
>
- Ordered
-
-
+
+ Level 1
+
+
+ Level 2
+
+
+ Level 3
+
+
+
+
+
+
+ Paragraph
+
+
+ List
+
+ }
>
- Unordered
-
-
-
-
-
-
+
+
+ Ordered
+
+
+ Unordered
+
+
+
+
+
+
+
+
diff --git a/docs/src/app/(private)/experiments/rtl.tsx b/docs/src/app/(private)/experiments/rtl.tsx
index e89c421e04..91f9c1c41e 100644
--- a/docs/src/app/(private)/experiments/rtl.tsx
+++ b/docs/src/app/(private)/experiments/rtl.tsx
@@ -20,181 +20,205 @@ export default function RtlNestedMenu() {
Menu.Trigger
-
-
-
-
- Text color
-
-
-
-
- Black
-
-
- Dark grey
-
-
+
+
+
+
+ Text color
+
+
+
- Accent
-
-
-
-
+
+
+ Black
+
+
+ Dark grey
+
+
+ Accent
+
+
+
+
+
-
-
- Style
-
-
-
-
-
- Heading
-
-
-
-
- Level 1
-
-
- Level 2
-
-
- Level 3
-
-
-
-
-
+
+ Style
+
+
+
- Paragraph
-
-
-
- List
-
-
-
-
- Ordered
-
-
- Unordered
-
-
-
-
-
-
-
+
+
+
+ Heading
+
+
+
+
+
+ Level 1
+
+
+ Level 2
+
+
+ Level 3
+
+
+
+
+
+
+ Paragraph
+
+
+
+ List
+
+
+
+
+
+ Ordered
+
+
+ Unordered
+
+
+
+
+
+
+
+
+
-
- Clear formatting
-
-
-
+
+ Clear formatting
+
+
+
+
PreviewCard.Trigger
-
-
-
- Base UI
-
- Unstyled React components and hooks (@base-ui-components/react), by
- @MUI_hq.
-
-
-
- 1 Following
-
-
- 1,000 Followers
-
-
-
-
-
+
+
+
+
+ Base UI
+
+ Unstyled React components and hooks (@base-ui-components/react), by
+ @MUI_hq.
+
+
+
+ 1 Following
+
+
+ 1,000 Followers
+
+
+
+
+
+
Popover.Trigger
-
-
- Popover Title
- Popover Description
-
-
-
+
+
+
+ Popover Title
+ Popover Description
+
+
+
+
diff --git a/docs/src/app/(private)/experiments/select-perf.tsx b/docs/src/app/(private)/experiments/select-perf.tsx
index 09690d790c..72918ac55e 100644
--- a/docs/src/app/(private)/experiments/select-perf.tsx
+++ b/docs/src/app/(private)/experiments/select-perf.tsx
@@ -30,38 +30,40 @@ function BaseSelectExample() {
>
-
-
-
- {items.map((item) => (
-
- {item}
-
- ))}
-
-
-
+
+
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
);
}
diff --git a/docs/src/app/(private)/experiments/tooltip.tsx b/docs/src/app/(private)/experiments/tooltip.tsx
index 7cd52e4a60..e205e70991 100644
--- a/docs/src/app/(private)/experiments/tooltip.tsx
+++ b/docs/src/app/(private)/experiments/tooltip.tsx
@@ -142,24 +142,30 @@ export default function TooltipTransitionExperiment() {
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
CSS Animation
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
CSS Transition Group
@@ -167,24 +173,30 @@ export default function TooltipTransitionExperiment() {
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
CSS Transition
Anchor
-
- Tooltip
-
+
+
+ Tooltip
+
+
CSS Transition with `@starting-style`
@@ -196,11 +208,13 @@ export default function TooltipTransitionExperiment() {
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
@@ -212,28 +226,36 @@ export default function TooltipTransitionExperiment() {
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
CSS Animation
Anchor
-
- Tooltip
-
+
+
+
+ Tooltip
+
+
+
CSS Transition Group
@@ -241,30 +263,36 @@ export default function TooltipTransitionExperiment() {
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
CSS Transition
Anchor
-
-
- Tooltip
-
-
+
+
+
+ Tooltip
+
+
+
@@ -282,20 +310,22 @@ function FramerMotion() {
Anchor
{isOpen && (
-
-
- }
- >
- Tooltip
-
-
+
+
+
+ }
+ >
+ Tooltip
+
+
+
)}
diff --git a/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx b/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx
index e58cd5fced..64253fda19 100644
--- a/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx
+++ b/docs/src/app/(public)/(content)/react/components/dialog/demos/hero/css-modules/index.tsx
@@ -6,7 +6,7 @@ export default function ExampleDialog() {
return (
View notifications
-
+
Notifications
diff --git a/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx b/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx
index edd8d28559..c7c0b7a762 100644
--- a/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx
+++ b/packages/react/src/alert-dialog/backdrop/AlertDialogBackdrop.tsx
@@ -24,7 +24,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop(
props: AlertDialogBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { render, className, keepMounted = false, ...other } = props;
+ const { render, className, ...other } = props;
const { open, nested, mounted, transitionStatus } = useAlertDialogRootContext();
const state: AlertDialogBackdrop.State = React.useMemo(
@@ -45,7 +45,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop(
});
// no need to render nested backdrops
- const shouldRender = (keepMounted || mounted) && !nested;
+ const shouldRender = !nested;
if (!shouldRender) {
return null;
}
@@ -54,13 +54,7 @@ const AlertDialogBackdrop = React.forwardRef(function AlertDialogBackdrop(
});
namespace AlertDialogBackdrop {
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the element in the DOM while the alert dialog is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
export interface State {
/**
@@ -85,11 +79,6 @@ AlertDialogBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the element in the DOM while the alert dialog is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx b/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx
index c9498fad98..1158b16ccc 100644
--- a/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx
+++ b/packages/react/src/alert-dialog/close/AlertDialogClose.test.tsx
@@ -11,7 +11,9 @@ describe(' ', () => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx b/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx
index be892fdfce..5b40b3718a 100644
--- a/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx
+++ b/packages/react/src/alert-dialog/description/AlertDialogDescription.test.tsx
@@ -11,7 +11,9 @@ describe(' ', () => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/alert-dialog/index.parts.ts b/packages/react/src/alert-dialog/index.parts.ts
index 87eb08a1e1..5eb6454af9 100644
--- a/packages/react/src/alert-dialog/index.parts.ts
+++ b/packages/react/src/alert-dialog/index.parts.ts
@@ -2,7 +2,7 @@ export { AlertDialogBackdrop as Backdrop } from './backdrop/AlertDialogBackdrop'
export { AlertDialogClose as Close } from './close/AlertDialogClose';
export { AlertDialogDescription as Description } from './description/AlertDialogDescription';
export { AlertDialogPopup as Popup } from './popup/AlertDialogPopup';
-export { Portal } from '../portal/Portal';
+export { AlertDialogPortal as Portal } from './portal/AlertDialogPortal';
export { AlertDialogRoot as Root } from './root/AlertDialogRoot';
export { AlertDialogTitle as Title } from './title/AlertDialogTitle';
export { AlertDialogTrigger as Trigger } from './trigger/AlertDialogTrigger';
diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx
index 241adfc903..42aa1f6acf 100644
--- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx
+++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.test.tsx
@@ -13,8 +13,10 @@ describe(' ', () => {
render: (node) => {
return render(
-
- {node}
+
+
+ {node}
+
,
);
},
@@ -24,7 +26,9 @@ describe(' ', () => {
const { getByTestId } = await render(
-
+
+
+
,
);
@@ -40,10 +44,12 @@ describe(' ', () => {
Open
-
-
- Close
-
+
+
+
+ Close
+
+
,
@@ -69,12 +75,14 @@ describe(' ', () => {
Open
-
-
-
-
- Close
-
+
+
+
+
+
+ Close
+
+
@@ -106,12 +114,14 @@ describe(' ', () => {
Open
-
-
-
-
- Close
-
+
+
+
+
+
+ Close
+
+
@@ -140,9 +150,11 @@ describe(' ', () => {
Open
-
- Close
-
+
+
+ Close
+
+
,
@@ -168,9 +180,11 @@ describe(' ', () => {
Open
-
- Close
-
+
+
+ Close
+
+
diff --git a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
index 54f050545b..eb413a108a 100644
--- a/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
+++ b/packages/react/src/alert-dialog/popup/AlertDialogPopup.tsx
@@ -15,6 +15,7 @@ import { InteractionType } from '../../utils/useEnhancedClickHandler';
import { transitionStatusMapping } from '../../utils/styleHookMapping';
import { AlertDialogPopupDataAttributes } from './AlertDialogPopupDataAttributes';
import { InternalBackdrop } from '../../utils/InternalBackdrop';
+import { useAlertDialogPortalContext } from '../portal/AlertDialogPortalContext';
const customStyleHookMapping: CustomStyleHookMapping = {
...baseMapping,
@@ -34,7 +35,7 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
props: AlertDialogPopup.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, id, keepMounted = false, render, initialFocus, finalFocus, ...other } = props;
+ const { className, id, render, initialFocus, finalFocus, ...other } = props;
const {
descriptionElementId,
@@ -54,6 +55,8 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
modal,
} = useAlertDialogRootContext();
+ useAlertDialogPortalContext();
+
const mergedRef = useForkRef(forwardedRef, popupRef);
const { getRootProps, floatingContext, resolvedInitialFocus } = useDialogPopup({
@@ -98,10 +101,6 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
customStyleHookMapping,
});
- if (!keepMounted && !mounted) {
- return null;
- }
-
return (
{mounted && modal && }
@@ -121,11 +120,6 @@ const AlertDialogPopup = React.forwardRef(function AlertDialogPopup(
namespace AlertDialogPopup {
export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the element in the DOM while the alert dialog is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Determines the element to focus when the dialog is opened.
* By default, the first focusable element is focused.
@@ -188,11 +182,6 @@ AlertDialogPopup.propTypes /* remove-proptypes */ = {
PropTypes.func,
refType,
]),
- /**
- * Whether to keep the element in the DOM while the alert dialog is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
new file mode 100644
index 0000000000..a9443cde41
--- /dev/null
+++ b/packages/react/src/alert-dialog/portal/AlertDialogPortal.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingPortal } from '@floating-ui/react';
+import { useAlertDialogRootContext } from '../root/AlertDialogRootContext';
+import { AlertDialogPortalContext } from './AlertDialogPortalContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
+
+/**
+ * A portal element that moves the popup to a different part of the DOM.
+ * By default, the portal element is appended to ``.
+ *
+ * Documentation: [Base UI Alert Dialog](https://base-ui.com/react/components/alert-dialog)
+ */
+function AlertDialogPortal(props: AlertDialogPortal.Props) {
+ const { children, keepMounted = false, container } = props;
+
+ const { mounted } = useAlertDialogRootContext();
+
+ const shouldRender = mounted || keepMounted;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+namespace AlertDialogPortal {
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
+}
+
+AlertDialogPortal.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A parent element to render the portal element into.
+ */
+ container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+} as any;
+
+export { AlertDialogPortal };
diff --git a/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts b/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts
new file mode 100644
index 0000000000..e9169df78e
--- /dev/null
+++ b/packages/react/src/alert-dialog/portal/AlertDialogPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const AlertDialogPortalContext = React.createContext(undefined);
+
+export function useAlertDialogPortalContext() {
+ const value = React.useContext(AlertDialogPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx
index 38c643668a..d7e4994535 100644
--- a/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx
+++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.test.tsx
@@ -17,9 +17,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -44,9 +46,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -69,9 +73,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -91,9 +97,11 @@ describe(' ', () => {
Open Dialog
-
- Close Dialog
-
+
+
+ Close Dialog
+
+
Another Button
diff --git a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
index 443757bc47..c4e0093131 100644
--- a/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
+++ b/packages/react/src/alert-dialog/root/AlertDialogRoot.tsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import type { DialogRoot } from '../../dialog/root/DialogRoot';
import { AlertDialogRootContext } from './AlertDialogRootContext';
import { useDialogRoot } from '../../dialog/root/useDialogRoot';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the alert dialog.
@@ -36,7 +35,7 @@ const AlertDialogRoot: React.FC = function AlertDialogRoo
return (
- {children}
+ {children}
);
};
diff --git a/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx b/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx
index b296255c70..538d75c32c 100644
--- a/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx
+++ b/packages/react/src/alert-dialog/title/AlertDialogTitle.test.tsx
@@ -11,7 +11,9 @@ describe(' ', () => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/dialog/backdrop/DialogBackdrop.tsx b/packages/react/src/dialog/backdrop/DialogBackdrop.tsx
index b2dae7eba6..c224c729d2 100644
--- a/packages/react/src/dialog/backdrop/DialogBackdrop.tsx
+++ b/packages/react/src/dialog/backdrop/DialogBackdrop.tsx
@@ -24,7 +24,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop(
props: DialogBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { render, className, keepMounted = false, ...other } = props;
+ const { render, className, ...other } = props;
const { open, nested, mounted, transitionStatus } = useDialogRootContext();
const state: DialogBackdrop.State = React.useMemo(
@@ -45,7 +45,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop(
});
// no need to render nested backdrops
- const shouldRender = (keepMounted || mounted) && !nested;
+ const shouldRender = !nested;
if (!shouldRender) {
return null;
}
@@ -54,13 +54,7 @@ const DialogBackdrop = React.forwardRef(function DialogBackdrop(
});
namespace DialogBackdrop {
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the dialog is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
export interface State {
/**
@@ -85,11 +79,6 @@ DialogBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the HTML element in the DOM while the dialog is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/dialog/close/DialogClose.test.tsx b/packages/react/src/dialog/close/DialogClose.test.tsx
index fb0cd7412a..f9a05b20c0 100644
--- a/packages/react/src/dialog/close/DialogClose.test.tsx
+++ b/packages/react/src/dialog/close/DialogClose.test.tsx
@@ -10,7 +10,9 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/dialog/description/DialogDescription.test.tsx b/packages/react/src/dialog/description/DialogDescription.test.tsx
index 546794e6b1..2eac5f6b93 100644
--- a/packages/react/src/dialog/description/DialogDescription.test.tsx
+++ b/packages/react/src/dialog/description/DialogDescription.test.tsx
@@ -10,7 +10,9 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/dialog/index.parts.ts b/packages/react/src/dialog/index.parts.ts
index d6df8f438f..c6b2a98d6f 100644
--- a/packages/react/src/dialog/index.parts.ts
+++ b/packages/react/src/dialog/index.parts.ts
@@ -2,7 +2,7 @@ export { DialogBackdrop as Backdrop } from './backdrop/DialogBackdrop';
export { DialogClose as Close } from './close/DialogClose';
export { DialogDescription as Description } from './description/DialogDescription';
export { DialogPopup as Popup } from './popup/DialogPopup';
-export { Portal } from '../portal/Portal';
+export { DialogPortal as Portal } from './portal/DialogPortal';
export { DialogRoot as Root } from './root/DialogRoot';
export { DialogTitle as Title } from './title/DialogTitle';
export { DialogTrigger as Trigger } from './trigger/DialogTrigger';
diff --git a/packages/react/src/dialog/popup/DialogPopup.test.tsx b/packages/react/src/dialog/popup/DialogPopup.test.tsx
index 13249250cf..aee3b0b4dc 100644
--- a/packages/react/src/dialog/popup/DialogPopup.test.tsx
+++ b/packages/react/src/dialog/popup/DialogPopup.test.tsx
@@ -15,7 +15,7 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+ {node}
,
);
},
@@ -30,7 +30,9 @@ describe(' ', () => {
it(`should ${!expectedIsMounted ? 'not ' : ''}keep the dialog mounted when keepMounted=${keepMounted}`, async () => {
const { queryByRole } = await render(
-
+
+
+
,
);
@@ -52,10 +54,12 @@ describe(' ', () => {
Open
-
-
- Close
-
+
+
+
+ Close
+
+
,
@@ -80,12 +84,14 @@ describe(' ', () => {
Open
-
-
-
-
- Close
-
+
+
+
+
+
+ Close
+
+
@@ -116,12 +122,14 @@ describe(' ', () => {
Open
-
-
-
-
- Close
-
+
+
+
+
+
+ Close
+
+
@@ -150,9 +158,11 @@ describe(' ', () => {
Open
-
- Close
-
+
+
+ Close
+
+
,
@@ -178,9 +188,11 @@ describe(' ', () => {
Open
-
- Close
-
+
+
+ Close
+
+
diff --git a/packages/react/src/dialog/popup/DialogPopup.tsx b/packages/react/src/dialog/popup/DialogPopup.tsx
index 0103b9be34..b17311b9c4 100644
--- a/packages/react/src/dialog/popup/DialogPopup.tsx
+++ b/packages/react/src/dialog/popup/DialogPopup.tsx
@@ -16,6 +16,7 @@ import { transitionStatusMapping } from '../../utils/styleHookMapping';
import { DialogPopupCssVars } from './DialogPopupCssVars';
import { DialogPopupDataAttributes } from './DialogPopupDataAttributes';
import { InternalBackdrop } from '../../utils/InternalBackdrop';
+import { useDialogPortalContext } from '../portal/DialogPortalContext';
const customStyleHookMapping: CustomStyleHookMapping = {
...baseMapping,
@@ -35,7 +36,7 @@ const DialogPopup = React.forwardRef(function DialogPopup(
props: DialogPopup.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, finalFocus, id, initialFocus, keepMounted = false, render, ...other } = props;
+ const { className, finalFocus, id, initialFocus, render, ...other } = props;
const {
descriptionElementId,
@@ -56,6 +57,8 @@ const DialogPopup = React.forwardRef(function DialogPopup(
transitionStatus,
} = useDialogRootContext();
+ useDialogPortalContext();
+
const mergedRef = useForkRef(forwardedRef, popupRef);
const { getRootProps, floatingContext, resolvedInitialFocus } = useDialogPopup({
@@ -94,10 +97,6 @@ const DialogPopup = React.forwardRef(function DialogPopup(
customStyleHookMapping,
});
- if (!keepMounted && !mounted) {
- return null;
- }
-
return (
{mounted && modal && }
@@ -118,11 +117,6 @@ const DialogPopup = React.forwardRef(function DialogPopup(
namespace DialogPopup {
export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the dialog is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Determines the element to focus when the dialog is opened.
* By default, the first focusable element is focused.
@@ -185,11 +179,6 @@ DialogPopup.propTypes /* remove-proptypes */ = {
PropTypes.func,
refType,
]),
- /**
- * Whether to keep the HTML element in the DOM while the dialog is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/dialog/portal/DialogPortal.tsx b/packages/react/src/dialog/portal/DialogPortal.tsx
new file mode 100644
index 0000000000..1f0a85ab52
--- /dev/null
+++ b/packages/react/src/dialog/portal/DialogPortal.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingPortal } from '@floating-ui/react';
+import { useDialogRootContext } from '../root/DialogRootContext';
+import { DialogPortalContext } from './DialogPortalContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
+
+/**
+ * A portal element that moves the popup to a different part of the DOM.
+ * By default, the portal element is appended to ``.
+ *
+ * Documentation: [Base UI Dialog](https://base-ui.com/react/components/dialog)
+ */
+function DialogPortal(props: DialogPortal.Props) {
+ const { children, keepMounted = false, container } = props;
+
+ const { mounted } = useDialogRootContext();
+
+ const shouldRender = mounted || keepMounted;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+namespace DialogPortal {
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
+}
+
+DialogPortal.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A parent element to render the portal element into.
+ */
+ container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+} as any;
+
+export { DialogPortal };
diff --git a/packages/react/src/dialog/portal/DialogPortalContext.ts b/packages/react/src/dialog/portal/DialogPortalContext.ts
new file mode 100644
index 0000000000..60a523f117
--- /dev/null
+++ b/packages/react/src/dialog/portal/DialogPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const DialogPortalContext = React.createContext(undefined);
+
+export function useDialogPortalContext() {
+ const value = React.useContext(DialogPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/dialog/root/DialogRoot.test.tsx b/packages/react/src/dialog/root/DialogRoot.test.tsx
index 6faa878510..b9a4f2b9f1 100644
--- a/packages/react/src/dialog/root/DialogRoot.test.tsx
+++ b/packages/react/src/dialog/root/DialogRoot.test.tsx
@@ -21,7 +21,9 @@ describe(' ', () => {
const { queryByRole, getByRole } = await render(
-
+
+
+
,
);
@@ -40,7 +42,9 @@ describe(' ', () => {
it('should open and close the dialog with the `open` prop', async () => {
const { queryByRole, setProps } = await render(
-
+
+
+
,
);
@@ -67,7 +71,9 @@ describe(' ', () => {
setOpen(false)}>Close
-
+
+
+
);
@@ -122,12 +128,13 @@ describe(' ', () => {
setOpen(false)}>Close
-
+
+
+
);
@@ -153,9 +160,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -180,9 +189,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -205,9 +216,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -223,9 +236,11 @@ describe(' ', () => {
const { user } = await render(
Open
-
- Close
-
+
+
+ Close
+
+
,
);
@@ -245,9 +260,11 @@ describe(' ', () => {
Open Dialog
-
- Close Dialog
-
+
+
+ Close Dialog
+
+
Another Button
@@ -288,9 +305,11 @@ describe(' ', () => {
Open Dialog
-
- Close Dialog
-
+
+
+ Close Dialog
+
+
Another Button
@@ -333,7 +352,9 @@ describe(' ', () => {
dismissible={dismissible}
modal={false}
>
-
+
+
+
,
);
@@ -377,7 +398,9 @@ describe(' ', () => {
{/* eslint-disable-next-line react/no-danger */}
-
+
+
+
,
);
diff --git a/packages/react/src/dialog/root/DialogRoot.tsx b/packages/react/src/dialog/root/DialogRoot.tsx
index 236a9d091e..faa1303c22 100644
--- a/packages/react/src/dialog/root/DialogRoot.tsx
+++ b/packages/react/src/dialog/root/DialogRoot.tsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { DialogRootContext, useOptionalDialogRootContext } from './DialogRootContext';
import { DialogContext } from '../utils/DialogContext';
import { type CommonParameters, useDialogRoot } from './useDialogRoot';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the dialog.
@@ -37,13 +36,12 @@ const DialogRoot = function DialogRoot(props: DialogRoot.Props) {
const nested = Boolean(parentDialogRootContext);
const dialogContextValue = React.useMemo(() => ({ ...dialogRoot, nested }), [dialogRoot, nested]);
-
const dialogRootContextValue = React.useMemo(() => ({ dismissible }), [dismissible]);
return (
- {children}
+ {children}
);
diff --git a/packages/react/src/dialog/title/DialogTitle.test.tsx b/packages/react/src/dialog/title/DialogTitle.test.tsx
index 4a5ab7c8a8..6112802d63 100644
--- a/packages/react/src/dialog/title/DialogTitle.test.tsx
+++ b/packages/react/src/dialog/title/DialogTitle.test.tsx
@@ -10,7 +10,9 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/field/root/FieldRoot.test.tsx b/packages/react/src/field/root/FieldRoot.test.tsx
index 56e34ef281..56c6205bdf 100644
--- a/packages/react/src/field/root/FieldRoot.test.tsx
+++ b/packages/react/src/field/root/FieldRoot.test.tsx
@@ -484,12 +484,14 @@ describe(' ', () => {
-
-
- Select
- Option 1
-
-
+
+
+
+ Select
+ Option 1
+
+
+
,
);
@@ -683,12 +685,14 @@ describe(' ', () => {
-
-
- Select
- Option 1
-
-
+
+
+
+ Select
+ Option 1
+
+
+
,
);
diff --git a/packages/react/src/menu/arrow/MenuArrow.test.tsx b/packages/react/src/menu/arrow/MenuArrow.test.tsx
index 852f633909..fe60ccc0de 100644
--- a/packages/react/src/menu/arrow/MenuArrow.test.tsx
+++ b/packages/react/src/menu/arrow/MenuArrow.test.tsx
@@ -10,9 +10,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
diff --git a/packages/react/src/menu/backdrop/MenuBackdrop.tsx b/packages/react/src/menu/backdrop/MenuBackdrop.tsx
index 7955ac1df8..0f6671d963 100644
--- a/packages/react/src/menu/backdrop/MenuBackdrop.tsx
+++ b/packages/react/src/menu/backdrop/MenuBackdrop.tsx
@@ -24,7 +24,7 @@ const MenuBackdrop = React.forwardRef(function MenuBackdrop(
props: MenuBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, render, keepMounted = false, ...other } = props;
+ const { className, render, ...other } = props;
const { open, mounted, transitionStatus } = useMenuRootContext();
const state: MenuBackdrop.State = React.useMemo(
@@ -44,11 +44,6 @@ const MenuBackdrop = React.forwardRef(function MenuBackdrop(
customStyleHookMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return renderElement();
});
@@ -61,13 +56,7 @@ namespace MenuBackdrop {
transitionStatus: TransitionStatus;
}
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the menu is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
}
MenuBackdrop.propTypes /* remove-proptypes */ = {
@@ -84,11 +73,6 @@ MenuBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the HTML element in the DOM while the menu is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/menu/checkbox-item-indicator/MenuCheckboxItemIndicator.test.tsx b/packages/react/src/menu/checkbox-item-indicator/MenuCheckboxItemIndicator.test.tsx
index 3cf0825e44..9fdcb58043 100644
--- a/packages/react/src/menu/checkbox-item-indicator/MenuCheckboxItemIndicator.test.tsx
+++ b/packages/react/src/menu/checkbox-item-indicator/MenuCheckboxItemIndicator.test.tsx
@@ -16,11 +16,13 @@ describe(' ', () => {
render(node) {
return render(
-
-
- {node}
-
-
+
+
+
+ {node}
+
+
+
,
);
},
@@ -39,13 +41,15 @@ describe(' ', () => {
setChecked(false)}>Close
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
@@ -98,18 +102,20 @@ describe(' ', () => {
setChecked(false)}>Close
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
diff --git a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
index d39bd9d53d..28c2cc79ac 100644
--- a/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
+++ b/packages/react/src/menu/checkbox-item/MenuCheckboxItem.test.tsx
@@ -74,22 +74,24 @@ describe(' ', () => {
const { getAllByRole } = await render(
-
-
- } id="item-1">
- 1
-
- } id="item-2">
- 2
-
- } id="item-3">
- 3
-
- } id="item-4">
- 4
-
-
-
+
+
+
+ } id="item-1">
+ 1
+
+ } id="item-2">
+ 2
+
+ } id="item-3">
+ 3
+
+ } id="item-4">
+ 4
+
+
+
+
,
);
@@ -140,11 +142,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -161,11 +165,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -188,11 +194,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -224,11 +232,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -253,11 +263,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -280,11 +292,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -309,11 +323,13 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -330,11 +346,13 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
diff --git a/packages/react/src/menu/group-label/MenuGroupLabel.test.tsx b/packages/react/src/menu/group-label/MenuGroupLabel.test.tsx
index ea3fafd8cf..35ebfb8afd 100644
--- a/packages/react/src/menu/group-label/MenuGroupLabel.test.tsx
+++ b/packages/react/src/menu/group-label/MenuGroupLabel.test.tsx
@@ -24,13 +24,15 @@ describe(' ', () => {
it('should have the role `presentation`', async () => {
const { getByText } = await render(
-
-
-
- Test group
-
-
-
+
+
+
+
+ Test group
+
+
+
+
,
);
@@ -41,13 +43,15 @@ describe(' ', () => {
it("should reference the generated id in Group's `aria-labelledby`", async () => {
const { getByText, getByRole } = await render(
-
-
-
- Test group
-
-
-
+
+
+
+
+ Test group
+
+
+
+
,
);
@@ -60,13 +64,15 @@ describe(' ', () => {
it("should reference the provided id in Group's `aria-labelledby`", async () => {
const { getByRole } = await render(
-
-
-
- Test group
-
-
-
+
+
+
+
+ Test group
+
+
+
+
,
);
diff --git a/packages/react/src/menu/index.parts.ts b/packages/react/src/menu/index.parts.ts
index 80cf69cc33..7976b4de94 100644
--- a/packages/react/src/menu/index.parts.ts
+++ b/packages/react/src/menu/index.parts.ts
@@ -6,7 +6,7 @@ export { MenuGroup as Group } from './group/MenuGroup';
export { MenuGroupLabel as GroupLabel } from './group-label/MenuGroupLabel';
export { MenuItem as Item } from './item/MenuItem';
export { MenuPopup as Popup } from './popup/MenuPopup';
-export { Portal } from '../portal/Portal';
+export { MenuPortal as Portal } from './portal/MenuPortal';
export { MenuPositioner as Positioner } from './positioner/MenuPositioner';
export { MenuRadioGroup as RadioGroup } from './radio-group/MenuRadioGroup';
export { MenuRadioItem as RadioItem } from './radio-item/MenuRadioItem';
diff --git a/packages/react/src/menu/item/MenuItem.test.tsx b/packages/react/src/menu/item/MenuItem.test.tsx
index a25baf0260..9a16f84a9f 100644
--- a/packages/react/src/menu/item/MenuItem.test.tsx
+++ b/packages/react/src/menu/item/MenuItem.test.tsx
@@ -56,13 +56,15 @@ describe(' ', () => {
const onClick = spy();
const { user } = await render(
-
-
-
- Item
-
-
-
+
+
+
+
+ Item
+
+
+
+
,
);
@@ -95,22 +97,24 @@ describe(' ', () => {
const { getAllByRole, user } = await render(
-
-
- } id="item-1">
- 1
-
- } id="item-2">
- 2
-
- } id="item-3">
- 3
-
- } id="item-4">
- 4
-
-
-
+
+
+
+ } id="item-1">
+ 1
+
+ } id="item-2">
+ 2
+
+ } id="item-3">
+ 3
+
+ } id="item-4">
+ 4
+
+
+
+
,
);
@@ -154,11 +158,13 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -175,11 +181,13 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
diff --git a/packages/react/src/menu/popup/MenuPopup.test.tsx b/packages/react/src/menu/popup/MenuPopup.test.tsx
index c566ea1b3e..ea0ee36c3e 100644
--- a/packages/react/src/menu/popup/MenuPopup.test.tsx
+++ b/packages/react/src/menu/popup/MenuPopup.test.tsx
@@ -9,7 +9,9 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/portal/Portal.tsx b/packages/react/src/menu/portal/MenuPortal.tsx
similarity index 66%
rename from packages/react/src/portal/Portal.tsx
rename to packages/react/src/menu/portal/MenuPortal.tsx
index 73cf76d330..0ebb386d17 100644
--- a/packages/react/src/portal/Portal.tsx
+++ b/packages/react/src/menu/portal/MenuPortal.tsx
@@ -2,46 +2,49 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { FloatingPortal } from '@floating-ui/react';
-import { usePortalContext } from './PortalContext';
-import { HTMLElementType, refType } from '../utils/proptypes';
+import { useMenuRootContext } from '../root/MenuRootContext';
+import { MenuPortalContext } from './MenuPortalContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
/**
* A portal element that moves the popup to a different part of the DOM.
* By default, the portal element is appended to ``.
*
- * Documentation: https://base-ui.com
+ * Documentation: [Base UI Menu](https://base-ui.com/react/components/menu)
*/
-function Portal(props: Portal.Props) {
- const { children, container, keepMounted = false } = props;
+function MenuPortal(props: MenuPortal.Props) {
+ const { children, keepMounted = false, container } = props;
- const mounted = usePortalContext();
+ const { mounted } = useMenuRootContext();
const shouldRender = mounted || keepMounted;
if (!shouldRender) {
return null;
}
- return {children} ;
+ return (
+
+ {children}
+
+ );
}
-namespace Portal {
+namespace MenuPortal {
export interface Props {
children?: React.ReactNode;
- /**
- * A parent element to render the portal into.
- */
- container?: HTMLElement | null | React.RefObject;
/**
* Whether to keep the portal mounted in the DOM while the popup is hidden.
* @default false
*/
keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
}
-
- export interface State {}
}
-Portal.propTypes /* remove-proptypes */ = {
+MenuPortal.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
@@ -51,7 +54,7 @@ Portal.propTypes /* remove-proptypes */ = {
*/
children: PropTypes.node,
/**
- * A parent element to render the portal into.
+ * A parent element to render the portal element into.
*/
container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
/**
@@ -61,4 +64,4 @@ Portal.propTypes /* remove-proptypes */ = {
keepMounted: PropTypes.bool,
} as any;
-export { Portal };
+export { MenuPortal };
diff --git a/packages/react/src/menu/portal/MenuPortalContext.ts b/packages/react/src/menu/portal/MenuPortalContext.ts
new file mode 100644
index 0000000000..fd5ed78517
--- /dev/null
+++ b/packages/react/src/menu/portal/MenuPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const MenuPortalContext = React.createContext(undefined);
+
+export function useMenuPortalContext() {
+ const value = React.useContext(MenuPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/menu/positioner/MenuPositioner.test.tsx b/packages/react/src/menu/positioner/MenuPositioner.test.tsx
index 934fb73d2f..73a97bd4fd 100644
--- a/packages/react/src/menu/positioner/MenuPositioner.test.tsx
+++ b/packages/react/src/menu/positioner/MenuPositioner.test.tsx
@@ -38,7 +38,9 @@ describe(' ', () => {
render: (node) => {
return render(
- {node}
+
+ {node}
+
,
);
},
@@ -59,18 +61,20 @@ describe(' ', () => {
return (
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
@@ -107,18 +111,20 @@ describe(' ', () => {
return (
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
@@ -157,18 +163,20 @@ describe(' ', () => {
return (
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
@@ -212,18 +220,20 @@ describe(' ', () => {
const { getByTestId } = await render(
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
,
);
@@ -239,12 +249,14 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
Toggle
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
,
);
@@ -267,12 +279,14 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
Toggle
-
-
- 1
- 2
-
-
+
+
+
+ 1
+ 2
+
+
+
,
);
diff --git a/packages/react/src/menu/positioner/MenuPositioner.tsx b/packages/react/src/menu/positioner/MenuPositioner.tsx
index 1552afa781..4a7bfc628c 100644
--- a/packages/react/src/menu/positioner/MenuPositioner.tsx
+++ b/packages/react/src/menu/positioner/MenuPositioner.tsx
@@ -18,6 +18,7 @@ import { BaseUIComponentProps } from '../../utils/types';
import { popupStateMapping } from '../../utils/popupStateMapping';
import { CompositeList } from '../../composite/list/CompositeList';
import { InternalBackdrop } from '../../utils/InternalBackdrop';
+import { useMenuPortalContext } from '../portal/MenuPortalContext';
/**
* Positions the menu popup against the trigger.
@@ -34,7 +35,6 @@ const MenuPositioner = React.forwardRef(function MenuPositioner(
positionMethod = 'absolute',
className,
render,
- keepMounted = false,
side,
align,
sideOffset = 0,
@@ -57,6 +57,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner(
setOpen,
modal,
} = useMenuRootContext();
+ const keepMounted = useMenuPortalContext();
const { events: menuEvents } = useFloatingTree()!;
@@ -90,6 +91,7 @@ const MenuPositioner = React.forwardRef(function MenuPositioner(
parentNodeId,
menuEvents,
setOpen,
+ keepMounted,
});
const state: MenuPositioner.State = React.useMemo(
@@ -134,11 +136,6 @@ const MenuPositioner = React.forwardRef(function MenuPositioner(
extraProps: otherProps,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return (
{mounted && modal && parentNodeId === null && }
@@ -235,11 +232,6 @@ MenuPositioner.propTypes /* remove-proptypes */ = {
top: PropTypes.number,
}),
]),
- /**
- * Whether to keep the HTML element in the DOM while the menu is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Determines which CSS `position` property to use.
* @default 'absolute'
diff --git a/packages/react/src/menu/positioner/useMenuPositioner.ts b/packages/react/src/menu/positioner/useMenuPositioner.ts
index 7823fb8479..46c5c89aae 100644
--- a/packages/react/src/menu/positioner/useMenuPositioner.ts
+++ b/packages/react/src/menu/positioner/useMenuPositioner.ts
@@ -15,7 +15,7 @@ import { useMenuRootContext } from '../root/MenuRootContext';
export function useMenuPositioner(
params: useMenuPositioner.Parameters,
): useMenuPositioner.ReturnValue {
- const { keepMounted, mounted, menuEvents, nodeId, parentNodeId, setOpen } = params;
+ const { mounted, menuEvents, nodeId, parentNodeId, setOpen } = params;
const { open } = useMenuRootContext();
@@ -34,7 +34,7 @@ export function useMenuPositioner(
(externalProps = {}) => {
const hiddenStyles: React.CSSProperties = {};
- if (keepMounted && !open) {
+ if (!open) {
hiddenStyles.pointerEvents = 'none';
}
@@ -47,7 +47,7 @@ export function useMenuPositioner(
},
});
},
- [keepMounted, open, positionerStyles, mounted],
+ [open, positionerStyles, mounted],
);
React.useEffect(() => {
@@ -144,11 +144,6 @@ export namespace useMenuPositioner {
* @default 5
*/
collisionPadding?: Padding;
- /**
- * Whether to keep the HTML element in the DOM while the menu is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Whether to maintain the menu in the viewport after
* the anchor element is scrolled out of view.
@@ -165,6 +160,10 @@ export namespace useMenuPositioner {
}
export interface Parameters extends SharedParameters {
+ /**
+ * Whether the portal is kept mounted in the DOM while the popup is closed.
+ */
+ keepMounted: boolean;
/**
* Whether the Menu is mounted.
*/
diff --git a/packages/react/src/menu/radio-item-indicator/MenuRadioItemIndicator.test.tsx b/packages/react/src/menu/radio-item-indicator/MenuRadioItemIndicator.test.tsx
index 672ccc7715..a896a44b83 100644
--- a/packages/react/src/menu/radio-item-indicator/MenuRadioItemIndicator.test.tsx
+++ b/packages/react/src/menu/radio-item-indicator/MenuRadioItemIndicator.test.tsx
@@ -12,13 +12,15 @@ describe(' ', () => {
render(node) {
return render(
-
-
-
- {node}
-
-
-
+
+
+
+
+ {node}
+
+
+
+
,
);
},
@@ -37,20 +39,22 @@ describe(' ', () => {
setValue('b')}>Close
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
+
+
);
@@ -103,23 +107,25 @@ describe(' ', () => {
setValue('b')}>Close
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
index 012555604d..5c6aa09b17 100644
--- a/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
+++ b/packages/react/src/menu/radio-item/MenuRadioItem.test.tsx
@@ -84,40 +84,42 @@ describe(' ', () => {
const { getAllByRole } = await render(
-
-
-
- }
- id="item-1"
- >
- 1
-
- }
- id="item-2"
- >
- 2
-
- }
- id="item-3"
- >
- 3
-
- }
- id="item-4"
- >
- 4
-
-
-
-
+
+
+
+
+ }
+ id="item-1"
+ >
+ 1
+
+ }
+ id="item-2"
+ >
+ 2
+
+ }
+ id="item-3"
+ >
+ 3
+
+ }
+ id="item-4"
+ >
+ 4
+
+
+
+
+
,
);
@@ -162,13 +164,15 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
-
- Item
-
-
-
+
+
+
+
+ Item
+
+
+
+
,
);
@@ -187,11 +191,13 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
- Item
-
-
+
+
+
+ Item
+
+
+
,
);
@@ -216,13 +222,15 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
-
- Item
-
-
-
+
+
+
+
+ Item
+
+
+
+
,
);
@@ -240,13 +248,15 @@ describe(' ', () => {
const { getByRole, user } = await render(
Open
-
-
-
- Item
-
-
-
+
+
+
+
+ Item
+
+
+
+
,
);
@@ -271,15 +281,17 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
-
-
- Item
-
-
-
-
+
+
+
+
+
+ Item
+
+
+
+
+
,
);
@@ -296,13 +308,15 @@ describe(' ', () => {
const { getByRole, queryByRole, user } = await render(
Open
-
-
-
- Item
-
-
-
+
+
+
+
+ Item
+
+
+
+
,
);
diff --git a/packages/react/src/menu/root/MenuRoot.test.tsx b/packages/react/src/menu/root/MenuRoot.test.tsx
index f13f33540f..5bb3303f03 100644
--- a/packages/react/src/menu/root/MenuRoot.test.tsx
+++ b/packages/react/src/menu/root/MenuRoot.test.tsx
@@ -20,13 +20,15 @@ describe(' ', () => {
const { getByRole, getByTestId } = await render(
Toggle
-
-
- 1
- 2
- 3
-
-
+
+
+
+ 1
+ 2
+ 3
+
+
+
,
);
@@ -65,13 +67,15 @@ describe(' ', () => {
const { getByRole, getByTestId } = await render(
Toggle
-
-
- 1
- 2
- 3
-
-
+
+
+
+ 1
+ 2
+ 3
+
+
+
,
);
@@ -103,14 +107,16 @@ describe(' ', () => {
const { getByRole, getByTestId } = await render(
Toggle
-
-
- 1
-
- 2
-
-
-
+
+
+
+ 1
+
+ 2
+
+
+
+
,
);
@@ -128,7 +134,7 @@ describe(' ', () => {
expect(item1).toHaveFocus();
});
- await userEvent.keyboard('[ArrowDown]');
+ await userEvent.keyboard('{ArrowDown}');
await waitFor(() => {
expect(item2).toHaveFocus();
@@ -149,16 +155,18 @@ describe(' ', () => {
const { getByText, getAllByRole } = await render(
-
-
- Aa
- Ba
- Bb
- Ca
- Cb
- Cd
-
-
+
+
+
+ Aa
+ Ba
+ Bb
+ Ca
+ Cb
+ Cd
+
+
+
,
);
@@ -194,14 +202,16 @@ describe(' ', () => {
const { getByRole, getAllByRole } = await render(
Toggle
-
-
- 1
- 2
- 3
- 4
-
-
+
+
+
+ 1
+ 2
+ 3
+ 4
+
+
+
,
);
@@ -249,19 +259,21 @@ describe(' ', () => {
const { getByText, getAllByRole } = await render(
-
-
- Aa
- Ba
-
-
- Nested Content
-
- {undefined}
- {null}
- Bc
-
-
+
+
+
+ Aa
+ Ba
+
+
+ Nested Content
+
+ {undefined}
+ {null}
+ Bc
+
+
+
,
);
@@ -295,14 +307,16 @@ describe(' ', () => {
const { getByText, getAllByRole } = await render(
-
-
- Aa
- Ba
- Bb
- Bą
-
-
+
+
+
+ Aa
+ Ba
+ Bb
+ Bą
+
+
+
,
);
@@ -336,14 +350,16 @@ describe(' ', () => {
const { getByText, getAllByRole } = await render(
-
-
- Aa
- ąa
- ąb
- ąc
-
-
+
+
+
+ Aa
+ ąa
+ ąb
+ ąc
+
+
+
,
);
@@ -373,13 +389,15 @@ describe(' ', () => {
const { getAllByRole } = await render(
-
-
- handleClick()}>Item One
- handleClick()}>Item Two
- handleClick()}>Item Three
-
-
+
+
+
+ handleClick()}>Item One
+ handleClick()}>Item Two
+ handleClick()}>Item Three
+
+
+
,
);
@@ -413,20 +431,24 @@ describe(' ', () => {
const { getByTestId, queryByTestId } = await render(
-
-
- 1
-
- 2
-
-
- 2.1
- 2.2
-
-
-
-
-
+
+
+
+ 1
+
+ 2
+
+
+
+ 2.1
+ 2.2
+
+
+
+
+
+
+
,
);
@@ -463,13 +485,15 @@ describe(' ', () => {
return (
Toggle
-
-
- 1
- 2
- 3
-
-
+
+
+
+ 1
+ 2
+ 3
+
+
+
);
}
@@ -539,11 +563,13 @@ describe(' ', () => {
Toggle
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
,
@@ -571,11 +597,13 @@ describe(' ', () => {
Toggle
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
,
@@ -598,20 +626,24 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
Open
-
-
- 1
-
- 2
-
-
- 2.1
- 2.2
-
-
-
-
-
+
+
+
+ 1
+
+ 2
+
+
+
+ 2.1
+ 2.2
+
+
+
+
+
+
+
,
);
@@ -645,20 +677,24 @@ describe(' ', () => {
const { getByRole, queryAllByRole } = await render(
Open
-
-
-
+
+
+
+
+
,
);
@@ -708,9 +744,11 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
@@ -765,12 +803,14 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
diff --git a/packages/react/src/menu/root/MenuRoot.tsx b/packages/react/src/menu/root/MenuRoot.tsx
index c56b0129e1..f91c486a89 100644
--- a/packages/react/src/menu/root/MenuRoot.tsx
+++ b/packages/react/src/menu/root/MenuRoot.tsx
@@ -5,7 +5,6 @@ import { FloatingTree } from '@floating-ui/react';
import { useDirection } from '../../direction-provider/DirectionContext';
import { MenuRootContext, useMenuRootContext } from './MenuRootContext';
import { MenuOrientation, useMenuRoot } from './useMenuRoot';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the menu.
@@ -74,18 +73,12 @@ const MenuRoot: React.FC = function MenuRoot(props) {
// set up a FloatingTree to provide the context to nested menus
return (
-
- {children}
-
+ {children}
);
}
- return (
-
- {children}
-
- );
+ return {children} ;
};
namespace MenuRoot {
diff --git a/packages/react/src/menu/trigger/MenuTrigger.test.tsx b/packages/react/src/menu/trigger/MenuTrigger.test.tsx
index b994e047fd..4ffb900856 100644
--- a/packages/react/src/menu/trigger/MenuTrigger.test.tsx
+++ b/packages/react/src/menu/trigger/MenuTrigger.test.tsx
@@ -62,9 +62,11 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
-
-
-
+
+
+
+
+
,
);
@@ -79,9 +81,11 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
Open
-
-
-
+
+
+
+
+
,
);
@@ -109,11 +113,13 @@ describe(' ', () => {
const { getByRole, queryByRole } = await render(
{buttonComponent}
-
-
- 1
-
-
+
+
+
+ 1
+
+
+
,
);
diff --git a/packages/react/src/popover/arrow/PopoverArrow.test.tsx b/packages/react/src/popover/arrow/PopoverArrow.test.tsx
index 8e2957129c..9238dd09ab 100644
--- a/packages/react/src/popover/arrow/PopoverArrow.test.tsx
+++ b/packages/react/src/popover/arrow/PopoverArrow.test.tsx
@@ -10,9 +10,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
diff --git a/packages/react/src/popover/backdrop/PopoverBackdrop.tsx b/packages/react/src/popover/backdrop/PopoverBackdrop.tsx
index 0ebee86807..1f2128bc10 100644
--- a/packages/react/src/popover/backdrop/PopoverBackdrop.tsx
+++ b/packages/react/src/popover/backdrop/PopoverBackdrop.tsx
@@ -24,7 +24,7 @@ const PopoverBackdrop = React.forwardRef(function PopoverBackdrop(
props: PopoverBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, render, keepMounted = false, ...other } = props;
+ const { className, render, ...other } = props;
const { open, mounted, transitionStatus } = usePopoverRootContext();
@@ -45,11 +45,6 @@ const PopoverBackdrop = React.forwardRef(function PopoverBackdrop(
customStyleHookMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return renderElement();
});
@@ -62,13 +57,7 @@ namespace PopoverBackdrop {
transitionStatus: TransitionStatus;
}
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the popover is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
}
PopoverBackdrop.propTypes /* remove-proptypes */ = {
@@ -85,11 +74,6 @@ PopoverBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the HTML element in the DOM while the popover is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/popover/close/PopoverClose.test.tsx b/packages/react/src/popover/close/PopoverClose.test.tsx
index 61119ccc57..72daf95943 100644
--- a/packages/react/src/popover/close/PopoverClose.test.tsx
+++ b/packages/react/src/popover/close/PopoverClose.test.tsx
@@ -12,9 +12,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
@@ -23,12 +25,14 @@ describe(' ', () => {
it('should close popover when clicked', async () => {
await render(
-
-
- Content
-
-
-
+
+
+
+ Content
+
+
+
+
,
);
diff --git a/packages/react/src/popover/description/PopoverDescription.test.tsx b/packages/react/src/popover/description/PopoverDescription.test.tsx
index 879ca9965c..91de1605e4 100644
--- a/packages/react/src/popover/description/PopoverDescription.test.tsx
+++ b/packages/react/src/popover/description/PopoverDescription.test.tsx
@@ -12,9 +12,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
@@ -23,11 +25,13 @@ describe(' ', () => {
it('describes the popup element with its id', async () => {
await render(
-
-
- Title
-
-
+
+
+
+ Title
+
+
+
,
);
diff --git a/packages/react/src/popover/index.parts.ts b/packages/react/src/popover/index.parts.ts
index 3507f883e8..f2b763ec11 100644
--- a/packages/react/src/popover/index.parts.ts
+++ b/packages/react/src/popover/index.parts.ts
@@ -1,6 +1,6 @@
export { PopoverRoot as Root } from './root/PopoverRoot';
export { PopoverTrigger as Trigger } from './trigger/PopoverTrigger';
-export { Portal } from '../portal/Portal';
+export { PopoverPortal as Portal } from './portal/PopoverPortal';
export { PopoverPositioner as Positioner } from './positioner/PopoverPositioner';
export { PopoverPopup as Popup } from './popup/PopoverPopup';
export { PopoverArrow as Arrow } from './arrow/PopoverArrow';
diff --git a/packages/react/src/popover/popup/PopoverPopup.test.tsx b/packages/react/src/popover/popup/PopoverPopup.test.tsx
index 545f4cf65d..74bc4f3039 100644
--- a/packages/react/src/popover/popup/PopoverPopup.test.tsx
+++ b/packages/react/src/popover/popup/PopoverPopup.test.tsx
@@ -12,7 +12,9 @@ describe(' ', () => {
render(node) {
return render(
- {node}
+
+ {node}
+
,
);
},
@@ -21,9 +23,11 @@ describe(' ', () => {
it('should render the children', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -37,12 +41,14 @@ describe(' ', () => {
Open
-
-
-
- Close
-
-
+
+
+
+
+ Close
+
+
+
,
@@ -67,14 +73,16 @@ describe(' ', () => {
Open
-
-
-
-
-
- Close
-
-
+
+
+
+
+
+
+ Close
+
+
+
@@ -105,14 +113,16 @@ describe(' ', () => {
Open
-
-
-
-
-
- Close
-
-
+
+
+
+
+
+
+ Close
+
+
+
@@ -140,11 +150,13 @@ describe(' ', () => {
Open
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
,
@@ -173,11 +185,13 @@ describe(' ', () => {
Open
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
diff --git a/packages/react/src/popover/portal/PopoverPortal.tsx b/packages/react/src/popover/portal/PopoverPortal.tsx
new file mode 100644
index 0000000000..080d1d06b4
--- /dev/null
+++ b/packages/react/src/popover/portal/PopoverPortal.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingPortal } from '@floating-ui/react';
+import { usePopoverRootContext } from '../root/PopoverRootContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
+import { PopoverPortalContext } from './PopoverPortalContext';
+
+/**
+ * A portal element that moves the popup to a different part of the DOM.
+ * By default, the portal element is appended to ``.
+ *
+ * Documentation: [Base UI Popover](https://base-ui.com/react/components/popover)
+ */
+function PopoverPortal(props: PopoverPortal.Props) {
+ const { children, keepMounted = false, container } = props;
+
+ const { mounted } = usePopoverRootContext();
+
+ const shouldRender = mounted || keepMounted;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+namespace PopoverPortal {
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
+}
+
+PopoverPortal.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A parent element to render the portal element into.
+ */
+ container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+} as any;
+
+export { PopoverPortal };
diff --git a/packages/react/src/popover/portal/PopoverPortalContext.ts b/packages/react/src/popover/portal/PopoverPortalContext.ts
new file mode 100644
index 0000000000..a47307eae9
--- /dev/null
+++ b/packages/react/src/popover/portal/PopoverPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const PopoverPortalContext = React.createContext(undefined);
+
+export function usePopoverPortalContext() {
+ const value = React.useContext(PopoverPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/popover/positioner/PopoverPositioner.test.tsx b/packages/react/src/popover/positioner/PopoverPositioner.test.tsx
index 7a6c547ef8..1f407ac9ed 100644
--- a/packages/react/src/popover/positioner/PopoverPositioner.test.tsx
+++ b/packages/react/src/popover/positioner/PopoverPositioner.test.tsx
@@ -10,7 +10,11 @@ describe(' ', () => {
describeConformance( , () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
- return render({node} );
+ return render(
+
+ {node}
+ ,
+ );
},
}));
@@ -18,7 +22,9 @@ describe(' ', () => {
it('has hidden attribute when closed', async () => {
await render(
-
+
+
+
,
);
@@ -28,7 +34,9 @@ describe(' ', () => {
it('does not have inert attribute when open', async () => {
await render(
-
+
+
+
,
);
diff --git a/packages/react/src/popover/positioner/PopoverPositioner.tsx b/packages/react/src/popover/positioner/PopoverPositioner.tsx
index 0b8ded616d..2536c191bf 100644
--- a/packages/react/src/popover/positioner/PopoverPositioner.tsx
+++ b/packages/react/src/popover/positioner/PopoverPositioner.tsx
@@ -10,6 +10,7 @@ import { HTMLElementType } from '../../utils/proptypes';
import type { BaseUIComponentProps } from '../../utils/types';
import type { Side, Align } from '../../utils/useAnchorPositioning';
import { popupStateMapping } from '../../utils/popupStateMapping';
+import { usePopoverPortalContext } from '../portal/PopoverPortalContext';
/**
* Positions the popover against the trigger.
@@ -25,7 +26,6 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
render,
className,
anchor,
- keepMounted = false,
positionMethod = 'absolute',
side = 'bottom',
align = 'center',
@@ -40,6 +40,7 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
const { floatingRootContext, open, mounted, setPositionerElement, popupRef, openMethod } =
usePopoverRootContext();
+ const keepMounted = usePopoverPortalContext();
const positioner = usePopoverPositioner({
anchor,
@@ -47,7 +48,6 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
positionMethod,
mounted,
open,
- keepMounted,
side,
sideOffset,
align,
@@ -58,6 +58,7 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
sticky,
popupRef,
openMethod,
+ keepMounted,
});
const state: PopoverPositioner.State = React.useMemo(
@@ -82,11 +83,6 @@ const PopoverPositioner = React.forwardRef(function PopoverPositioner(
customStyleHookMapping: popupStateMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return (
{renderElement()}
@@ -178,11 +174,6 @@ PopoverPositioner.propTypes /* remove-proptypes */ = {
top: PropTypes.number,
}),
]),
- /**
- * Whether to keep the HTML element in the DOM while the popover is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Determines which CSS `position` property to use.
* @default 'absolute'
diff --git a/packages/react/src/popover/positioner/usePopoverPositioner.tsx b/packages/react/src/popover/positioner/usePopoverPositioner.tsx
index 6d9456b5ec..68ec63b80a 100644
--- a/packages/react/src/popover/positioner/usePopoverPositioner.tsx
+++ b/packages/react/src/popover/positioner/usePopoverPositioner.tsx
@@ -13,7 +13,7 @@ import { InteractionType } from '../../utils/useEnhancedClickHandler';
export function usePopoverPositioner(
params: usePopoverPositioner.Parameters,
): usePopoverPositioner.ReturnValue {
- const { open = false, keepMounted = false, mounted } = params;
+ const { open = false, mounted } = params;
const {
positionerStyles,
@@ -31,7 +31,7 @@ export function usePopoverPositioner(
(externalProps = {}) => {
const hiddenStyles: React.CSSProperties = {};
- if (keepMounted && !open) {
+ if (!open) {
hiddenStyles.pointerEvents = 'none';
}
@@ -44,7 +44,7 @@ export function usePopoverPositioner(
},
});
},
- [keepMounted, open, mounted, positionerStyles],
+ [open, mounted, positionerStyles],
);
return React.useMemo(
@@ -132,11 +132,6 @@ export namespace usePopoverPositioner {
* @default 5
*/
arrowPadding?: number;
- /**
- * Whether to keep the HTML element in the DOM while the popover is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Whether the popover continuously tracks its anchor after the initial positioning upon mount.
* @default true
@@ -145,6 +140,10 @@ export namespace usePopoverPositioner {
}
export interface Parameters extends SharedParameters {
+ /**
+ * Whether the portal is kept mounted in the DOM while the popup is closed.
+ */
+ keepMounted: boolean;
/**
* Whether the popover is mounted.
*/
diff --git a/packages/react/src/popover/root/PopoverRoot.test.tsx b/packages/react/src/popover/root/PopoverRoot.test.tsx
index dba1471bd3..57f811692b 100644
--- a/packages/react/src/popover/root/PopoverRoot.test.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.test.tsx
@@ -32,9 +32,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -51,9 +53,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -75,9 +79,11 @@ describe(' ', () => {
it('should open when controlled open is true', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -87,9 +93,11 @@ describe(' ', () => {
it('should close when controlled open is false', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -111,9 +119,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -153,9 +163,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -189,9 +201,11 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
@@ -247,12 +261,14 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
@@ -276,9 +292,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -289,9 +307,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -302,9 +322,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -315,9 +337,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -338,9 +362,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -368,9 +394,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -404,11 +432,13 @@ describe(' ', () => {
Toggle
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
,
@@ -435,11 +465,13 @@ describe(' ', () => {
const { user } = await render(
Toggle
-
-
- Close
-
-
+
+
+
+ Close
+
+
+
,
);
@@ -478,9 +510,11 @@ describe(' ', () => {
Toggle
-
-
-
+
+
+
+
+
,
diff --git a/packages/react/src/popover/root/PopoverRoot.tsx b/packages/react/src/popover/root/PopoverRoot.tsx
index 1feeb4dd2c..9fedbbf161 100644
--- a/packages/react/src/popover/root/PopoverRoot.tsx
+++ b/packages/react/src/popover/root/PopoverRoot.tsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { PopoverRootContext } from './PopoverRootContext';
import { usePopoverRoot } from './usePopoverRoot';
import { OPEN_DELAY } from '../utils/constants';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the popover.
@@ -98,9 +97,7 @@ const PopoverRoot: React.FC = function PopoverRoot(props) {
);
return (
-
- {props.children}
-
+ {props.children}
);
};
diff --git a/packages/react/src/popover/title/PopoverTitle.test.tsx b/packages/react/src/popover/title/PopoverTitle.test.tsx
index 3aaecd40dc..b4047511c7 100644
--- a/packages/react/src/popover/title/PopoverTitle.test.tsx
+++ b/packages/react/src/popover/title/PopoverTitle.test.tsx
@@ -12,9 +12,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
@@ -23,11 +25,13 @@ describe(' ', () => {
it('labels the popup element with its id', async () => {
await render(
-
-
- Title
-
-
+
+
+
+ Title
+
+
+
,
);
diff --git a/packages/react/src/portal/PortalContext.ts b/packages/react/src/portal/PortalContext.ts
deleted file mode 100644
index 6f5c4fb5e5..0000000000
--- a/packages/react/src/portal/PortalContext.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import * as React from 'react';
-
-export const PortalContext = React.createContext(undefined);
-
-if (process.env.NODE_ENV !== 'production') {
- PortalContext.displayName = 'PortalContext';
-}
-
-export function usePortalContext() {
- const context = React.useContext(PortalContext);
- if (context === undefined) {
- throw new Error(
- 'Base UI: PortalContext is missing. Portal parts must be placed within the Root of a component.',
- );
- }
- return context;
-}
diff --git a/packages/react/src/preview-card/arrow/PreviewCardArrow.test.tsx b/packages/react/src/preview-card/arrow/PreviewCardArrow.test.tsx
index 1cff6f27c8..1dc6dce3d2 100644
--- a/packages/react/src/preview-card/arrow/PreviewCardArrow.test.tsx
+++ b/packages/react/src/preview-card/arrow/PreviewCardArrow.test.tsx
@@ -10,9 +10,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
diff --git a/packages/react/src/preview-card/backdrop/PreviewCardBackdrop.tsx b/packages/react/src/preview-card/backdrop/PreviewCardBackdrop.tsx
index 99c9424006..947e2a658e 100644
--- a/packages/react/src/preview-card/backdrop/PreviewCardBackdrop.tsx
+++ b/packages/react/src/preview-card/backdrop/PreviewCardBackdrop.tsx
@@ -24,7 +24,7 @@ const PreviewCardBackdrop = React.forwardRef(function PreviewCardBackdrop(
props: PreviewCardBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { render, className, keepMounted = false, ...other } = props;
+ const { render, className, ...other } = props;
const { open, mounted, transitionStatus } = usePreviewCardRootContext();
@@ -45,11 +45,6 @@ const PreviewCardBackdrop = React.forwardRef(function PreviewCardBackdrop(
customStyleHookMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return renderElement();
});
@@ -62,13 +57,7 @@ namespace PreviewCardBackdrop {
transitionStatus: TransitionStatus;
}
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the preview card is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
}
PreviewCardBackdrop.propTypes /* remove-proptypes */ = {
@@ -85,11 +74,6 @@ PreviewCardBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the HTML element in the DOM while the preview card is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/preview-card/index.parts.ts b/packages/react/src/preview-card/index.parts.ts
index 8048ba6e7a..aa072531aa 100644
--- a/packages/react/src/preview-card/index.parts.ts
+++ b/packages/react/src/preview-card/index.parts.ts
@@ -1,5 +1,5 @@
export { PreviewCardRoot as Root } from './root/PreviewCardRoot';
-export { Portal } from '../portal/Portal';
+export { PreviewCardPortal as Portal } from './portal/PreviewCardPortal';
export { PreviewCardTrigger as Trigger } from './trigger/PreviewCardTrigger';
export { PreviewCardPositioner as Positioner } from './positioner/PreviewCardPositioner';
export { PreviewCardPopup as Popup } from './popup/PreviewCardPopup';
diff --git a/packages/react/src/preview-card/popup/PreviewCardPopup.test.tsx b/packages/react/src/preview-card/popup/PreviewCardPopup.test.tsx
index a96810cba4..93b976ed18 100644
--- a/packages/react/src/preview-card/popup/PreviewCardPopup.test.tsx
+++ b/packages/react/src/preview-card/popup/PreviewCardPopup.test.tsx
@@ -12,7 +12,9 @@ describe(' ', () => {
render(node) {
return render(
- {node}
+
+ {node}
+
,
);
},
@@ -21,9 +23,11 @@ describe(' ', () => {
it('should render the children', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
diff --git a/packages/react/src/preview-card/portal/PreviewCardPortal.tsx b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx
new file mode 100644
index 0000000000..b9b92c7103
--- /dev/null
+++ b/packages/react/src/preview-card/portal/PreviewCardPortal.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingPortal } from '@floating-ui/react';
+import { usePreviewCardRootContext } from '../root/PreviewCardContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
+import { PreviewCardPortalContext } from './PreviewCardPortalContext';
+
+/**
+ * A portal element that moves the popup to a different part of the DOM.
+ * By default, the portal element is appended to ``.
+ *
+ * Documentation: [Base UI Preview Card](https://base-ui.com/react/components/preview-card)
+ */
+function PreviewCardPortal(props: PreviewCardPortal.Props) {
+ const { children, keepMounted = false, container } = props;
+
+ const { mounted } = usePreviewCardRootContext();
+
+ const shouldRender = mounted || keepMounted;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+namespace PreviewCardPortal {
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
+}
+
+PreviewCardPortal.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A parent element to render the portal element into.
+ */
+ container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+} as any;
+
+export { PreviewCardPortal };
diff --git a/packages/react/src/preview-card/portal/PreviewCardPortalContext.ts b/packages/react/src/preview-card/portal/PreviewCardPortalContext.ts
new file mode 100644
index 0000000000..5e0e9d83f1
--- /dev/null
+++ b/packages/react/src/preview-card/portal/PreviewCardPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const PreviewCardPortalContext = React.createContext(undefined);
+
+export function usePreviewCardPortalContext() {
+ const value = React.useContext(PreviewCardPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx
index 22838875ed..d7c728e533 100644
--- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx
+++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.test.tsx
@@ -8,7 +8,11 @@ describe(' ', () => {
describeConformance( , () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
- return render({node} );
+ return render(
+
+ {node}
+ ,
+ );
},
}));
});
diff --git a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx
index 6f69caae0e..08f9dc095f 100644
--- a/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx
+++ b/packages/react/src/preview-card/positioner/PreviewCardPositioner.tsx
@@ -10,6 +10,7 @@ import { HTMLElementType } from '../../utils/proptypes';
import type { Side, Align } from '../../utils/useAnchorPositioning';
import type { BaseUIComponentProps } from '../../utils/types';
import { popupStateMapping } from '../../utils/popupStateMapping';
+import { usePreviewCardPortalContext } from '../portal/PreviewCardPortalContext';
/**
* Positions the popup against the trigger.
@@ -34,11 +35,11 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner(
collisionPadding = 5,
arrowPadding = 5,
sticky = false,
- keepMounted = false,
...otherProps
} = props;
const { open, mounted, floatingRootContext, setPositionerElement } = usePreviewCardRootContext();
+ const keepMounted = usePreviewCardPortalContext();
const positioner = usePreviewCardPositioner({
anchor,
@@ -46,7 +47,6 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner(
positionMethod,
open,
mounted,
- keepMounted,
side,
sideOffset,
align,
@@ -55,6 +55,7 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner(
collisionBoundary,
collisionPadding,
sticky,
+ keepMounted,
});
const state: PreviewCardPositioner.State = React.useMemo(
@@ -96,11 +97,6 @@ const PreviewCardPositioner = React.forwardRef(function PreviewCardPositioner(
customStyleHookMapping: popupStateMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return (
{renderElement()}
@@ -192,11 +188,6 @@ PreviewCardPositioner.propTypes /* remove-proptypes */ = {
top: PropTypes.number,
}),
]),
- /**
- * Whether to keep the HTML element in the DOM while the preview card is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Determines which CSS `position` property to use.
* @default 'absolute'
diff --git a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts
index 82bc387025..2228d1e050 100644
--- a/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts
+++ b/packages/react/src/preview-card/positioner/usePreviewCardPositioner.ts
@@ -13,7 +13,7 @@ import { usePreviewCardRootContext } from '../root/PreviewCardContext';
export function usePreviewCardPositioner(
params: usePreviewCardPositioner.Parameters,
): usePreviewCardPositioner.ReturnValue {
- const { keepMounted, mounted } = params;
+ const { mounted } = params;
const { open } = usePreviewCardRootContext();
@@ -33,7 +33,7 @@ export function usePreviewCardPositioner(
(externalProps = {}) => {
const hiddenStyles: React.CSSProperties = {};
- if (keepMounted && !open) {
+ if (!open) {
hiddenStyles.pointerEvents = 'none';
}
@@ -46,7 +46,7 @@ export function usePreviewCardPositioner(
},
});
},
- [positionerStyles, open, keepMounted, mounted],
+ [positionerStyles, open, mounted],
);
return React.useMemo(
@@ -134,11 +134,6 @@ export namespace usePreviewCardPositioner {
* @default 5
*/
arrowPadding?: number;
- /**
- * Whether to keep the HTML element in the DOM while the preview card is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Whether the preview card popup continuously tracks its anchor after the initial positioning
* upon mount.
@@ -148,6 +143,10 @@ export namespace usePreviewCardPositioner {
}
export interface Parameters extends SharedParameters {
+ /**
+ * Whether the portal is kept mounted in the DOM while the popup is closed.
+ */
+ keepMounted: boolean;
/**
* Whether the preview card is mounted.
*/
diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
index f90c216bb4..876e5e84b0 100644
--- a/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
+++ b/packages/react/src/preview-card/root/PreviewCardRoot.test.tsx
@@ -32,9 +32,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -55,9 +57,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -87,9 +91,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -108,9 +114,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -131,9 +139,11 @@ describe(' ', () => {
it('should open when controlled open is true', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -143,9 +153,11 @@ describe(' ', () => {
it('should close when controlled open is false', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -166,9 +178,11 @@ describe(' ', () => {
setOpen(false)}>Close
-
- Content
-
+
+
+ Content
+
+
);
@@ -223,14 +237,16 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
- Content
-
-
+
+
+
+ Content
+
+
+
);
@@ -267,9 +283,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -314,9 +332,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -347,9 +367,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -360,9 +382,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -373,9 +397,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -386,9 +412,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -411,9 +439,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -441,9 +471,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
diff --git a/packages/react/src/preview-card/root/PreviewCardRoot.tsx b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
index 00055bc04a..0cc3e40816 100644
--- a/packages/react/src/preview-card/root/PreviewCardRoot.tsx
+++ b/packages/react/src/preview-card/root/PreviewCardRoot.tsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { PreviewCardRootContext } from './PreviewCardContext';
import { usePreviewCardRoot } from './usePreviewCardRoot';
import { CLOSE_DELAY, OPEN_DELAY } from '../utils/constants';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the preview card.
@@ -79,7 +78,7 @@ const PreviewCardRoot: React.FC = function PreviewCardRoo
return (
- {props.children}
+ {props.children}
);
};
diff --git a/packages/react/src/select/backdrop/SelectBackdrop.tsx b/packages/react/src/select/backdrop/SelectBackdrop.tsx
index f45cf3f592..ef7dcdea37 100644
--- a/packages/react/src/select/backdrop/SelectBackdrop.tsx
+++ b/packages/react/src/select/backdrop/SelectBackdrop.tsx
@@ -24,7 +24,7 @@ const SelectBackdrop = React.forwardRef(function SelectBackdrop(
props: SelectBackdrop.Props,
forwardedRef: React.ForwardedRef,
) {
- const { className, render, keepMounted = false, ...other } = props;
+ const { className, render, ...other } = props;
const { open, mounted, transitionStatus } = useSelectRootContext();
@@ -42,22 +42,11 @@ const SelectBackdrop = React.forwardRef(function SelectBackdrop(
customStyleHookMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return renderElement();
});
namespace SelectBackdrop {
- export interface Props extends BaseUIComponentProps<'div', State> {
- /**
- * Whether to keep the HTML element in the DOM while the select menu is hidden.
- * @default false
- */
- keepMounted?: boolean;
- }
+ export interface Props extends BaseUIComponentProps<'div', State> {}
export interface State {
/**
@@ -82,11 +71,6 @@ SelectBackdrop.propTypes /* remove-proptypes */ = {
* returns a class based on the component’s state.
*/
className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
- /**
- * Whether to keep the HTML element in the DOM while the select menu is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Allows you to replace the component’s HTML element
* with a different tag, or compose it with another component.
diff --git a/packages/react/src/select/item/SelectItem.test.tsx b/packages/react/src/select/item/SelectItem.test.tsx
index b14ac7c073..a1d561e2fa 100644
--- a/packages/react/src/select/item/SelectItem.test.tsx
+++ b/packages/react/src/select/item/SelectItem.test.tsx
@@ -53,13 +53,15 @@ describe(' ', () => {
-
-
- one
- two
- three
-
-
+
+
+
+ one
+ two
+ three
+
+
+
,
);
@@ -92,12 +94,14 @@ describe(' ', () => {
-
-
- one
- two
-
-
+
+
+
+ one
+ two
+
+
+
,
);
@@ -123,14 +127,16 @@ describe(' ', () => {
-
-
- one
-
- two
-
-
-
+
+
+
+ one
+
+ two
+
+
+
+
,
);
@@ -152,13 +158,15 @@ describe(' ', () => {
-
-
- one
- two
- three
-
-
+
+
+
+ one
+ two
+ three
+
+
+
,
);
@@ -189,12 +197,14 @@ describe(' ', () => {
const { user } = await render(
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -219,12 +229,14 @@ describe(' ', () => {
await render(
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
diff --git a/packages/react/src/select/item/SelectItem.tsx b/packages/react/src/select/item/SelectItem.tsx
index 47f29d739a..62cfb0846a 100644
--- a/packages/react/src/select/item/SelectItem.tsx
+++ b/packages/react/src/select/item/SelectItem.tsx
@@ -233,16 +233,28 @@ const SelectItem = React.forwardRef(function SelectItem(
const listItem = useCompositeListItem({ label });
const { activeIndex, selectedIndex, setActiveIndex } = useSelectIndexContext();
- const { getItemProps, setOpen, setValue, open, selectionRef, typingRef, valuesRef, popupRef } =
- useSelectRootContext();
+ const {
+ getItemProps,
+ setOpen,
+ setValue,
+ open,
+ selectionRef,
+ typingRef,
+ valuesRef,
+ popupRef,
+ registerSelectedItem,
+ value,
+ } = useSelectRootContext();
+ const itemRef = React.useRef(null);
const selectedIndexRef = useLatestRef(selectedIndex);
const indexRef = useLatestRef(listItem.index);
+ const mergedRef = useForkRef(listItem.ref, forwardedRef, itemRef);
- const mergedRef = useForkRef(listItem.ref, forwardedRef);
+ const hasRegistered = listItem.index !== -1;
useEnhancedEffect(() => {
- if (listItem.index === -1) {
+ if (!hasRegistered) {
return undefined;
}
@@ -252,7 +264,13 @@ const SelectItem = React.forwardRef(function SelectItem(
return () => {
delete values[listItem.index];
};
- }, [listItem.index, valueProp, valuesRef]);
+ }, [hasRegistered, listItem.index, valueProp, valuesRef]);
+
+ useEnhancedEffect(() => {
+ if (hasRegistered && valueProp === value) {
+ registerSelectedItem(listItem.index);
+ }
+ }, [hasRegistered, listItem.index, registerSelectedItem, valueProp, value]);
const highlighted = activeIndex === listItem.index;
const selected = selectedIndex === listItem.index;
diff --git a/packages/react/src/select/popup/SelectPopup.test.tsx b/packages/react/src/select/popup/SelectPopup.test.tsx
index 7351110949..93907e1bb1 100644
--- a/packages/react/src/select/popup/SelectPopup.test.tsx
+++ b/packages/react/src/select/popup/SelectPopup.test.tsx
@@ -10,7 +10,9 @@ describe(' ', () => {
render(node) {
return render(
- {node}
+
+ {node}
+
,
);
},
diff --git a/packages/react/src/select/portal/SelectPortal.tsx b/packages/react/src/select/portal/SelectPortal.tsx
index 4d188f30da..0a1f0c5e6f 100644
--- a/packages/react/src/select/portal/SelectPortal.tsx
+++ b/packages/react/src/select/portal/SelectPortal.tsx
@@ -1,8 +1,9 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
-import { Portal } from '../../portal/Portal';
+import { FloatingPortal } from '@floating-ui/react';
import { HTMLElementType, refType } from '../../utils/proptypes';
+import { SelectPortalContext } from './SelectPortalContext';
/**
* A portal element that moves the popup to a different part of the DOM.
@@ -12,16 +13,22 @@ import { HTMLElementType, refType } from '../../utils/proptypes';
*/
function SelectPortal(props: SelectPortal.Props) {
const { children, container } = props;
+
return (
-
- {children}
-
+
+ {children}
+
);
}
namespace SelectPortal {
- export interface Props extends Omit {}
- export interface State extends Portal.State {}
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
}
SelectPortal.propTypes /* remove-proptypes */ = {
@@ -34,7 +41,7 @@ SelectPortal.propTypes /* remove-proptypes */ = {
*/
children: PropTypes.node,
/**
- * A parent element to render the portal into.
+ * A parent element to render the portal element into.
*/
container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
} as any;
diff --git a/packages/react/src/select/portal/SelectPortalContext.ts b/packages/react/src/select/portal/SelectPortalContext.ts
new file mode 100644
index 0000000000..1e8ac9d425
--- /dev/null
+++ b/packages/react/src/select/portal/SelectPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const SelectPortalContext = React.createContext(undefined);
+
+export function useSelectPortalContext() {
+ const value = React.useContext(SelectPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/select/positioner/SelectPositioner.test.tsx b/packages/react/src/select/positioner/SelectPositioner.test.tsx
index ec9ab3fef2..ac2e00e865 100644
--- a/packages/react/src/select/positioner/SelectPositioner.test.tsx
+++ b/packages/react/src/select/positioner/SelectPositioner.test.tsx
@@ -8,7 +8,11 @@ describe(' ', () => {
describeConformance( , () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
- return render({node} );
+ return render(
+
+ {node}
+ ,
+ );
},
}));
});
diff --git a/packages/react/src/select/positioner/useSelectPositioner.ts b/packages/react/src/select/positioner/useSelectPositioner.ts
index d672929ac6..e0aa0be52d 100644
--- a/packages/react/src/select/positioner/useSelectPositioner.ts
+++ b/packages/react/src/select/positioner/useSelectPositioner.ts
@@ -144,11 +144,6 @@ export namespace useSelectPositioner {
* @default 5
*/
collisionPadding?: Padding;
- /**
- * Whether to keep the HTML element in the DOM while the select menu is hidden.
- * @default true
- */
- keepMounted?: boolean;
/**
* Whether to maintain the select menu in the viewport after
* the anchor element is scrolled out of view.
diff --git a/packages/react/src/select/root/SelectRoot.test.tsx b/packages/react/src/select/root/SelectRoot.test.tsx
index 8110d4b23b..ced457dba4 100644
--- a/packages/react/src/select/root/SelectRoot.test.tsx
+++ b/packages/react/src/select/root/SelectRoot.test.tsx
@@ -6,6 +6,10 @@ import { expect } from 'chai';
import { spy } from 'sinon';
describe(' ', () => {
+ beforeEach(() => {
+ (globalThis as any).BASE_UI_ANIMATIONS_DISABLED = true;
+ });
+
const { render } = createRenderer();
describe('prop: defaultValue', () => {
@@ -15,12 +19,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -44,12 +50,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -71,12 +79,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -120,12 +130,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
);
}
@@ -153,12 +165,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -174,12 +188,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
);
}
@@ -209,9 +225,11 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
@@ -266,12 +284,14 @@ describe(' ', () => {
setOpen(false)}>Close
-
-
-
+
+
+
+
+
);
@@ -287,8 +307,6 @@ describe(' ', () => {
});
expect(animationFinished).to.equal(true);
-
- (globalThis as any).BASE_UI_ANIMATIONS_DISABLED = true;
});
});
@@ -301,12 +319,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
@@ -324,12 +344,14 @@ describe(' ', () => {
-
-
- a
- b
-
-
+
+
+
+ a
+ b
+
+
+
,
);
diff --git a/packages/react/src/select/root/SelectRoot.tsx b/packages/react/src/select/root/SelectRoot.tsx
index c7848d5f23..2f48d6498d 100644
--- a/packages/react/src/select/root/SelectRoot.tsx
+++ b/packages/react/src/select/root/SelectRoot.tsx
@@ -6,7 +6,6 @@ import { SelectRootContext } from './SelectRootContext';
import { SelectIndexContext } from './SelectIndexContext';
import { useFieldRootContext } from '../../field/root/FieldRootContext';
import { visuallyHidden } from '../../utils/visuallyHidden';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the select.
@@ -65,9 +64,7 @@ const SelectRoot: SelectRoot = function SelectRoot(
return (
-
- {props.children}
-
+ {props.children}
;
modal: boolean;
+ registerSelectedItem: (index: number) => void;
}
export const SelectRootContext = React.createContext(null);
diff --git a/packages/react/src/select/root/useSelectRoot.ts b/packages/react/src/select/root/useSelectRoot.ts
index ea66f29e15..c1c381eaa8 100644
--- a/packages/react/src/select/root/useSelectRoot.ts
+++ b/packages/react/src/select/root/useSelectRoot.ts
@@ -140,20 +140,31 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect
setLabel(labelsRef.current[index] ?? '');
});
+ const hasRegisteredRef = React.useRef(false);
+
+ const registerSelectedItem = useEventCallback((suppliedIndex: number | undefined) => {
+ if (suppliedIndex !== undefined) {
+ hasRegisteredRef.current = true;
+ }
+
+ const stringValue = typeof value === 'string' || value === null ? value : JSON.stringify(value);
+ const index = suppliedIndex ?? 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.`);
+ }
+ });
+
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]);
+ if (!hasRegisteredRef.current) {
+ return;
+ }
+
+ registerSelectedItem(undefined);
+ }, [value, registerSelectedItem]);
const floatingRootContext = useFloatingRootContext({
open,
@@ -263,6 +274,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect
transitionStatus,
fieldControlValidation,
modal,
+ registerSelectedItem,
}),
[
id,
@@ -290,6 +302,7 @@ export function useSelectRoot(params: useSelectRoot.Parameters): useSelect
transitionStatus,
fieldControlValidation,
modal,
+ registerSelectedItem,
],
);
diff --git a/packages/react/src/tooltip/arrow/TooltipArrow.test.tsx b/packages/react/src/tooltip/arrow/TooltipArrow.test.tsx
index 0f29392280..5942263e56 100644
--- a/packages/react/src/tooltip/arrow/TooltipArrow.test.tsx
+++ b/packages/react/src/tooltip/arrow/TooltipArrow.test.tsx
@@ -10,9 +10,11 @@ describe(' ', () => {
render(node) {
return render(
-
- {node}
-
+
+
+ {node}
+
+
,
);
},
diff --git a/packages/react/src/tooltip/index.parts.ts b/packages/react/src/tooltip/index.parts.ts
index 9e850090ea..937a51b96f 100644
--- a/packages/react/src/tooltip/index.parts.ts
+++ b/packages/react/src/tooltip/index.parts.ts
@@ -1,6 +1,6 @@
export { TooltipRoot as Root } from './root/TooltipRoot';
export { TooltipTrigger as Trigger } from './trigger/TooltipTrigger';
-export { Portal } from '../portal/Portal';
+export { TooltipPortal as Portal } from './portal/TooltipPortal';
export { TooltipPositioner as Positioner } from './positioner/TooltipPositioner';
export { TooltipPopup as Popup } from './popup/TooltipPopup';
export { TooltipArrow as Arrow } from './arrow/TooltipArrow';
diff --git a/packages/react/src/tooltip/popup/TooltipPopup.test.tsx b/packages/react/src/tooltip/popup/TooltipPopup.test.tsx
index c1d4557795..7170bc7adc 100644
--- a/packages/react/src/tooltip/popup/TooltipPopup.test.tsx
+++ b/packages/react/src/tooltip/popup/TooltipPopup.test.tsx
@@ -12,7 +12,9 @@ describe(' ', () => {
render(node) {
return render(
- {node}
+
+ {node}
+
,
);
},
@@ -21,9 +23,11 @@ describe(' ', () => {
it('should render the children', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
diff --git a/packages/react/src/tooltip/portal/TooltipPortal.tsx b/packages/react/src/tooltip/portal/TooltipPortal.tsx
new file mode 100644
index 0000000000..02014be2f2
--- /dev/null
+++ b/packages/react/src/tooltip/portal/TooltipPortal.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { FloatingPortal } from '@floating-ui/react';
+import { useTooltipRootContext } from '../root/TooltipRootContext';
+import { HTMLElementType, refType } from '../../utils/proptypes';
+import { TooltipPortalContext } from './TooltipPortalContext';
+
+/**
+ * A portal element that moves the popup to a different part of the DOM.
+ * By default, the portal element is appended to ``.
+ *
+ * Documentation: [Base UI Tooltip](https://base-ui.com/react/components/tooltip)
+ */
+function TooltipPortal(props: TooltipPortal.Props) {
+ const { children, keepMounted = false, container } = props;
+
+ const { mounted } = useTooltipRootContext();
+
+ const shouldRender = mounted || keepMounted;
+ if (!shouldRender) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+namespace TooltipPortal {
+ export interface Props {
+ children?: React.ReactNode;
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted?: boolean;
+ /**
+ * A parent element to render the portal element into.
+ */
+ container?: HTMLElement | null | React.RefObject;
+ }
+}
+
+TooltipPortal.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * A parent element to render the portal element into.
+ */
+ container: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([HTMLElementType, refType]),
+ /**
+ * Whether to keep the portal mounted in the DOM while the popup is hidden.
+ * @default false
+ */
+ keepMounted: PropTypes.bool,
+} as any;
+
+export { TooltipPortal };
diff --git a/packages/react/src/tooltip/portal/TooltipPortalContext.ts b/packages/react/src/tooltip/portal/TooltipPortalContext.ts
new file mode 100644
index 0000000000..78c2456adc
--- /dev/null
+++ b/packages/react/src/tooltip/portal/TooltipPortalContext.ts
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+export const TooltipPortalContext = React.createContext(undefined);
+
+export function useTooltipPortalContext() {
+ const value = React.useContext(TooltipPortalContext);
+ if (value === undefined) {
+ throw new Error('Base UI: is missing.');
+ }
+ return value;
+}
diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx
index 882fed0f25..fcf030421a 100644
--- a/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx
+++ b/packages/react/src/tooltip/positioner/TooltipPositioner.test.tsx
@@ -8,7 +8,11 @@ describe(' ', () => {
describeConformance( , () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
- return render({node} );
+ return render(
+
+ {node}
+ ,
+ );
},
}));
});
diff --git a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx
index 8d9b5e5eb9..4a08765820 100644
--- a/packages/react/src/tooltip/positioner/TooltipPositioner.tsx
+++ b/packages/react/src/tooltip/positioner/TooltipPositioner.tsx
@@ -10,6 +10,7 @@ import { useTooltipPositioner } from './useTooltipPositioner';
import type { BaseUIComponentProps } from '../../utils/types';
import type { Side, Align } from '../../utils/useAnchorPositioning';
import { popupStateMapping } from '../../utils/popupStateMapping';
+import { useTooltipPortalContext } from '../portal/TooltipPortalContext';
/**
* Positions the tooltip against the trigger.
@@ -25,7 +26,6 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner(
render,
className,
anchor,
- keepMounted = false,
positionMethod = 'absolute',
side = 'top',
align = 'center',
@@ -40,6 +40,7 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner(
const { open, setPositionerElement, mounted, floatingRootContext, trackCursorAxis } =
useTooltipRootContext();
+ const keepMounted = useTooltipPortalContext();
const positioner = useTooltipPositioner({
anchor,
@@ -47,7 +48,6 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner(
positionMethod,
open,
mounted,
- keepMounted,
side,
sideOffset,
align,
@@ -57,6 +57,7 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner(
sticky,
trackCursorAxis,
arrowPadding,
+ keepMounted,
});
const mergedRef = useForkRef(forwardedRef, setPositionerElement);
@@ -91,11 +92,6 @@ const TooltipPositioner = React.forwardRef(function TooltipPositioner(
customStyleHookMapping: popupStateMapping,
});
- const shouldRender = keepMounted || mounted;
- if (!shouldRender) {
- return null;
- }
-
return (
{renderElement()}
@@ -187,11 +183,6 @@ TooltipPositioner.propTypes /* remove-proptypes */ = {
top: PropTypes.number,
}),
]),
- /**
- * Whether to keep the HTML element in the DOM while the tooltip is hidden.
- * @default false
- */
- keepMounted: PropTypes.bool,
/**
* Determines which CSS `position` property to use.
* @default 'absolute'
diff --git a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts
index 4bfa1e1a16..59ca294b7f 100644
--- a/packages/react/src/tooltip/positioner/useTooltipPositioner.ts
+++ b/packages/react/src/tooltip/positioner/useTooltipPositioner.ts
@@ -8,7 +8,7 @@ import { useTooltipRootContext } from '../root/TooltipRootContext';
export function useTooltipPositioner(
params: useTooltipPositioner.Parameters,
): useTooltipPositioner.ReturnValue {
- const { keepMounted, mounted } = params;
+ const { mounted } = params;
const { open, trackCursorAxis } = useTooltipRootContext();
@@ -27,7 +27,7 @@ export function useTooltipPositioner(
(externalProps = {}) => {
const hiddenStyles: React.CSSProperties = {};
- if (keepMounted && !open) {
+ if (!open) {
hiddenStyles.pointerEvents = 'none';
}
@@ -44,7 +44,7 @@ export function useTooltipPositioner(
},
});
},
- [keepMounted, open, trackCursorAxis, mounted, positionerStyles],
+ [open, trackCursorAxis, mounted, positionerStyles],
);
return React.useMemo(
@@ -134,11 +134,6 @@ export namespace useTooltipPositioner {
* @default 5
*/
arrowPadding?: number;
- /**
- * Whether to keep the HTML element in the DOM while the tooltip is hidden.
- * @default false
- */
- keepMounted?: boolean;
/**
* Whether the tooltip continuously tracks its anchor after the initial positioning upon
* mount.
@@ -157,6 +152,10 @@ export namespace useTooltipPositioner {
}
export interface Parameters extends SharedParameters {
+ /**
+ * Whether the portal is kept mounted in the DOM while the popup is closed.
+ */
+ keepMounted: boolean;
/**
* Whether the tooltip is mounted.
*/
diff --git a/packages/react/src/tooltip/provider/TooltipProvider.test.tsx b/packages/react/src/tooltip/provider/TooltipProvider.test.tsx
index feae3cc0be..8424bd0bf6 100644
--- a/packages/react/src/tooltip/provider/TooltipProvider.test.tsx
+++ b/packages/react/src/tooltip/provider/TooltipProvider.test.tsx
@@ -16,9 +16,11 @@ describe(' ', () => {
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -50,9 +52,11 @@ describe(' ', () => {
-
- Content
-
+
+
+ Content
+
+
,
);
diff --git a/packages/react/src/tooltip/root/TooltipRoot.test.tsx b/packages/react/src/tooltip/root/TooltipRoot.test.tsx
index 70c8c637ab..ed19f1c704 100644
--- a/packages/react/src/tooltip/root/TooltipRoot.test.tsx
+++ b/packages/react/src/tooltip/root/TooltipRoot.test.tsx
@@ -22,9 +22,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -45,9 +47,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -76,9 +80,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -95,9 +101,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -126,9 +134,11 @@ describe(' ', () => {
it('should open when controlled open is true', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -138,9 +148,11 @@ describe(' ', () => {
it('should close when controlled open is false', async () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -162,9 +174,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -207,9 +221,11 @@ describe(' ', () => {
}}
>
-
- Content
-
+
+
+ Content
+
+
);
}
@@ -238,9 +254,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -253,9 +271,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -268,9 +288,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -283,9 +305,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -310,9 +334,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
@@ -340,9 +366,11 @@ describe(' ', () => {
await render(
-
- Content
-
+
+
+ Content
+
+
,
);
diff --git a/packages/react/src/tooltip/root/TooltipRoot.tsx b/packages/react/src/tooltip/root/TooltipRoot.tsx
index ed29bc7304..6f5789f710 100644
--- a/packages/react/src/tooltip/root/TooltipRoot.tsx
+++ b/packages/react/src/tooltip/root/TooltipRoot.tsx
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import { TooltipRootContext } from './TooltipRootContext';
import { useTooltipRoot } from './useTooltipRoot';
import { OPEN_DELAY } from '../utils/constants';
-import { PortalContext } from '../../portal/PortalContext';
/**
* Groups all parts of the tooltip.
@@ -82,9 +81,7 @@ const TooltipRoot: React.FC = function TooltipRoot(props) {
);
return (
-
- {props.children}
-
+ {props.children}
);
};
diff --git a/packages/react/src/utils/useAnchorPositioning.ts b/packages/react/src/utils/useAnchorPositioning.ts
index 5fc6b328c6..a3c51b09e7 100644
--- a/packages/react/src/utils/useAnchorPositioning.ts
+++ b/packages/react/src/utils/useAnchorPositioning.ts
@@ -42,7 +42,7 @@ interface UseAnchorPositioningParameters {
collisionBoundary?: Boundary;
collisionPadding?: Padding;
sticky?: boolean;
- keepMounted?: boolean;
+ keepMounted: boolean;
arrowPadding?: number;
floatingRootContext?: FloatingRootContext;
mounted: boolean;