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 Logo -

Base UI

-

- Unstyled React components and hooks (@base-ui-components/react), by - @MUI_hq. -

-
- - 1 Following - - - 1,000 Followers - -
- -
-
+ + + + Base UI Logo +

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 - - - - + + + + + + , @@ -69,12 +75,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -106,12 +114,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -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 + + 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 - - - - + + + + + + , @@ -80,12 +84,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -116,12 +122,14 @@ describe('', () => { Open - - - - - - + + + + + + + + @@ -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('', () => {
- + + +
); @@ -122,12 +128,13 @@ describe('', () => {