Repository: mui/mui-x
Pull request #16920: [TimeRangePicker] Shift popper between start and end input on multi input field
Author: login: LukasTy
Labels: name: plan: Pro
description: Impact at least one Pro user.
name: scope: pickers
description: Changes related to the date/time pickers.
name: type: enhancement
description: It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.
Created: 2025-03-11T16:04:51Z | Merged: 2025-03-14T10:11:23Z

Follow up on https://github.com/mui/mui-x/pull/9431.

Adds `triggerRef` shifting between start and end fields when using Picker with a `MultiInputTimeRangeField`.


https://github.com/user-attachments/assets/172a2ba1-4348-44f0-9d9d-fa6d94e5e7eb




Diff:
diff --git a/packages/x-date-pickers-pro/src/MultiInputTimeRangeField/MultiInputTimeRangeField.tsx b/packages/x-date-pickers-pro/src/MultiInputTimeRangeField/MultiInputTimeRangeField.tsx
index 49d4fb104ffd3..e4fa940ab7a17 100644
--- a/packages/x-date-pickers-pro/src/MultiInputTimeRangeField/MultiInputTimeRangeField.tsx
+++ b/packages/x-date-pickers-pro/src/MultiInputTimeRangeField/MultiInputTimeRangeField.tsx
@@ -44,6 +44,7 @@ const MultiInputTimeRangeField = createMultiInputTimeRangeField({
   name: 'MuiMultiInputTimeRangeField',
   getUtilityClass: getMultiInputTimeRangeFieldUtilityClass,
   useManager: useTimeRangeManager,
+  allowTriggerShifting: true,
 }) as MultiInputTimeRangeFieldComponent;
 
 MultiInputTimeRangeField.propTypes = {
diff --git a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.tsx b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.tsx
index 2ce25c896ca4c..37a03df525004 100644
--- a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.tsx
+++ b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.tsx
@@ -29,6 +29,7 @@ export function createMultiInputRangeField<TManager extends PickerAnyRangeManage
   useManager,
   name,
   getUtilityClass,
+  allowTriggerShifting,
 }: CreateMultiInputRangeFieldParameters<TManager>): CreateMultiInputRangeFieldReturnValue<TManager> {
   const useUtilityClasses = (classes: MultiInputRangeFieldProps<TManager>['classes']) => {
     const slots = {
@@ -106,8 +107,18 @@ export function createMultiInputRangeField<TManager extends PickerAnyRangeManage
       className: clsx(className, classes.root),
     });
 
-    const startTextFieldProps = useTextFieldProps({ slotProps, ownerState, position: 'start' });
-    const endTextFieldProps = useTextFieldProps({ slotProps, ownerState, position: 'end' });
+    const startTextFieldProps = useTextFieldProps({
+      slotProps,
+      ownerState,
+      position: 'start',
+      allowTriggerShifting,
+    });
+    const endTextFieldProps = useTextFieldProps({
+      slotProps,
+      ownerState,
+      position: 'end',
+      allowTriggerShifting,
+    });
 
     const fieldResponse = useMultiInputRangeField({
       manager,
diff --git a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.types.ts b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.types.ts
index 00890f1f81eb1..fde0802adb0dc 100644
--- a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.types.ts
+++ b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/createMultiInputRangeField.types.ts
@@ -92,6 +92,7 @@ export interface CreateMultiInputRangeFieldParameters<TManager extends PickerAny
       enableAccessibleFieldDOMStructure: PickerManagerEnableAccessibleFieldDOMStructure<TManager>;
     },
   ) => TManager;
+  allowTriggerShifting?: boolean;
 }
 
 export type CreateMultiInputRangeFieldReturnValue<TManager extends PickerAnyRangeManager> = ((
diff --git a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/useTextFieldProps.ts b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/useTextFieldProps.ts
index c3a39d346d31a..2898ec744c94b 100644
--- a/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/useTextFieldProps.ts
+++ b/packages/x-date-pickers-pro/src/internals/utils/createMultiInputRangeField/useTextFieldProps.ts
@@ -15,10 +15,12 @@ export function useTextFieldProps({
   slotProps,
   ownerState,
   position,
+  allowTriggerShifting,
 }: {
   slotProps: MultiInputRangeFieldSlotProps | undefined;
   ownerState: FieldOwnerState;
   position: 'start' | 'end';
+  allowTriggerShifting?: boolean;
 }): PickersTextFieldProps {
   const pickerContext = useNullablePickerContext();
   const translations = usePickerTranslations();
@@ -43,8 +45,14 @@ export function useTextFieldProps({
     textFieldProps.InputProps = {};
   }
 
-  if (pickerContext && position === 'start') {
-    textFieldProps.InputProps.ref = pickerContext.triggerRef;
+  if (pickerContext) {
+    if (!allowTriggerShifting) {
+      if (position === 'start') {
+        textFieldProps.InputProps.ref = pickerContext.triggerRef;
+      }
+    } else if (rangePositionContext?.rangePosition === position) {
+      textFieldProps.InputProps.ref = pickerContext.triggerRef;
+    }
   }
 
   return textFieldProps;
diff --git a/packages/x-date-pickers/src/internals/components/PickerPopper/PickerPopper.tsx b/packages/x-date-pickers/src/internals/components/PickerPopper/PickerPopper.tsx
index 0eefac8b4e4e3..0caabc251a975 100644
--- a/packages/x-date-pickers/src/internals/components/PickerPopper/PickerPopper.tsx
+++ b/packages/x-date-pickers/src/internals/components/PickerPopper/PickerPopper.tsx
@@ -331,8 +331,9 @@ export function PickerPopper(inProps: PickerPopperProps) {
   const props = useThemeProps({ props: inProps, name: 'MuiPickerPopper' });
   const { children, placement = 'bottom-start', slots, slotProps, classes: classesProp } = props;
 
-  const { open, triggerRef, popupRef, reduceAnimations } = usePickerContext();
-  const { dismissViews, getCurrentViewMode, viewContainerRole } = usePickerPrivateContext();
+  const { open, popupRef, reduceAnimations } = usePickerContext();
+  const { dismissViews, getCurrentViewMode, triggerElement, viewContainerRole } =
+    usePickerPrivateContext();
 
   React.useEffect(() => {
     function handleKeyDown(nativeEvent: KeyboardEvent) {
@@ -423,7 +424,7 @@ export function PickerPopper(inProps: PickerPopperProps) {
       role: viewContainerRole == null ? undefined : viewContainerRole,
       open,
       placement,
-      anchorEl: triggerRef.current,
+      anchorEl: triggerElement,
       onKeyDown: handleKeyDown,
     },
     className: classes.root,
diff --git a/packages/x-date-pickers/src/internals/components/PickerProvider.tsx b/packages/x-date-pickers/src/internals/components/PickerProvider.tsx
index da33201f2027c..8fb5bf07d7ab3 100644
--- a/packages/x-date-pickers/src/internals/components/PickerProvider.tsx
+++ b/packages/x-date-pickers/src/internals/components/PickerProvider.tsx
@@ -34,12 +34,13 @@ export const PickerPrivateContext = React.createContext<PickerPrivateContextValu
     pickerVariant: 'desktop',
     pickerOrientation: 'portrait',
   },
+  rootRefObject: { current: null },
+  labelId: undefined,
   dismissViews: () => {},
   hasUIView: true,
   getCurrentViewMode: () => 'UI',
-  rootRefObject: { current: null },
+  triggerElement: null,
   viewContainerRole: null,
-  labelId: undefined,
 });
 
 /**
@@ -167,7 +168,7 @@ export interface PickerContextValue<
    * The ref to attach to the element that triggers the Picker opening.
    * When using a built-in field component, this property is automatically attached to the right element.
    */
-  triggerRef: React.RefObject<any>;
+  triggerRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
   /**
    * The status of the element that triggers the Picker opening.
    * If it is "hidden", the field should not render the UI to open the Picker.
@@ -309,10 +310,23 @@ export interface SetValueActionOptions<TError = string | null> {
 }
 
 export interface PickerPrivateContextValue {
+  /*
+   * Close the Picker and accept the current value if it is not equal to the last accepted value.
+   */
+  dismissViews: () => void;
   /**
-   * The ownerState of the Picker.
+   * The ownerState of the picker.
    */
   ownerState: PickerOwnerState;
+  /**
+   * Whether at least one view has an UI (it has a view renderer associated).
+   */
+  hasUIView: boolean;
+  /**
+   * Return the mode of the current view.
+   * @returns {boolean} The mode of the current view.
+   */
+  getCurrentViewMode: () => 'UI' | 'field';
   /**
    * The ref of the root element.
    * This is the object counterpart of the `usePickerContext().rootRef` property which can be a function.
@@ -322,19 +336,10 @@ export interface PickerPrivateContextValue {
    * The id of the label element.
    */
   labelId: string | undefined;
-  /*
-   * Close the Picker and accept the current value if it is not equal to the last accepted value.
-   */
-  dismissViews: () => void;
   /**
-   * Whether at least one view has an UI (it has a view renderer associated).
+   * The element used as the anchor for the Picker Popper.
    */
-  hasUIView: boolean;
-  /**
-   * Return the mode of the current view.
-   * @returns {boolean} The mode of the current view.
-   */
-  getCurrentViewMode: () => 'UI' | 'field';
+  triggerElement: HTMLElement | null;
   /**
    * The aria role associated with the view container.
    * It is equal to "dialog" when the view is rendered inside a `@mui/material/Dialog`.
diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx
index e4e942a63d969..9b9b435461ea1 100644
--- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx
+++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePicker.tsx
@@ -87,7 +87,7 @@ export const usePicker = <
   /**
    * Refs
    */
-  const triggerRef = React.useRef<HTMLElement>(null);
+  const [triggerElement, triggerRef] = React.useState<HTMLElement | null>(null);
   const popupRef = React.useRef<HTMLElement>(null);
   const fieldRef = React.useRef<FieldRef<PickerValue> | FieldRef<PickerRangeValue> | null>(null);
   const rootRefObject = React.useRef<HTMLDivElement>(null);
@@ -299,7 +299,6 @@ export const usePicker = <
       name,
       label,
       sx,
-      triggerRef,
       triggerStatus,
       timezone,
       state.open,
@@ -313,20 +312,21 @@ export const usePicker = <
   const privateContextValue = React.useMemo<PickerPrivateContextValue>(
     () => ({
       dismissViews,
+      ownerState,
       hasUIView,
       getCurrentViewMode,
-      ownerState,
       rootRefObject,
       labelId,
+      triggerElement,
       viewContainerRole,
     }),
     [
       dismissViews,
+      ownerState,
       hasUIView,
       getCurrentViewMode,
-      ownerState,
-      rootRefObject,
       labelId,
+      triggerElement,
       viewContainerRole,
     ],
   );


Reviews:
user: login: flaviendelangle
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T13:11:16Z
user: login: LukasTy
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T15:11:33Z
user: login: flaviendelangle
body: I would like a behavior double check of @noraleonte 
But for me it make a lot of sense to enable it only on our MUI multi input field and only for the time range picker.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T15:24:17Z
user: login: flaviendelangle
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-12T15:24:32Z
user: login: noraleonte
body: The behavior makes a lot of sense to me 👌 Thanks for the effort on this one 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T09:48:46Z

Review comments:
diff_hunk: @@ -167,7 +167,8 @@ export interface PickerContextValue<
    * The ref to attach to the element that triggers the Picker opening.
    * When using a built-in field component, this property is automatically attached to the right element.
    */
-  triggerRef: React.RefObject<any>;
+  triggerRef: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
+  triggerRefElement: HTMLElement | null;
path: packages/x-date-pickers/src/internals/components/PickerProvider.tsx
user: login: flaviendelangle
body: I think we can keep it on the private context.
And I would name it `triggerElement`, since it's not a ref anymore.

created_at: 2025-03-12T13:11:09Z
author_association: MEMBER
diff_hunk: @@ -309,10 +310,23 @@ export interface SetValueActionOptions<TError = string | null> {
 }
 
 export interface PickerPrivateContextValue {
+  /*
path: packages/x-date-pickers/src/internals/components/PickerProvider.tsx
user: login: LukasTy
body: I just moved the attributes around to sort them alphabetically. 🙈 
created_at: 2025-03-12T15:11:33Z
author_association: MEMBER

PR comments:
user: login: mui-bot
created_at: 2025-03-11T16:08:36Z
body: 
<!--
  0 failure: 
  0 warning: 
  
  1 markdown notices
  DangerID: danger-id-Danger;
-->



**Deploy preview:** <a href="https://deploy-preview-16920--material-ui-x.netlify.app/">https://deploy-preview-16920--material-ui-x.netlify.app/</a>
<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against b5eaa7feacc47723a48d637e35d61bd7c171eef8
</p>

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-12T15:08:40Z
body: This pull request has conflicts, please resolve those before we can evaluate the pull request.
author_association: CONTRIBUTOR