Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { spy } from 'sinon';
import {
screen,
fireEvent,
createEvent,
within,
fireTouchChangedEvent,
waitFor,
Expand Down Expand Up @@ -435,6 +436,70 @@ describe('<DateRangeCalendar />', () => {
).to.have.lengthOf(10);
},
);

it('should handle drag events targeting child elements inside the day button', () => {
// This test validates the fix for when drag events target child elements (e.g., text spans)
// inside the day button, rather than the button itself. The fix uses .closest() to find
// the ancestor with the data-timestamp attribute.
const onChange = spy();
const initialValue: [any, any] = [
adapterToUse.date('2018-01-10'),
adapterToUse.date('2018-01-31'),
];
render(<DateRangeCalendar onChange={onChange} defaultValue={initialValue} />);

const startDayButton = screen.getByRole('gridcell', { name: '31', selected: true });
const endDayButton = screen.getByRole('gridcell', { name: '29' });

// Create synthetic child elements inside the buttons to simulate the real browser scenario
// where drag events can target child elements (e.g., text spans, TouchRipple).
// This ensures the `.closest()` fallback path is exercised.
const startDayChild = document.createElement('span');
startDayButton.appendChild(startDayChild);
const endDayChild = document.createElement('span');
endDayButton.appendChild(endDayChild);

// Execute drag using child elements as targets
// This simulates a user clicking on the day number text or ripple effect
const createDragEventOnChild = (
type: 'dragStart' | 'dragEnter' | 'dragOver' | 'drop' | 'dragEnd' | 'dragLeave',
target: Element,
) => {
const createdEvent = createEvent[type](target);
Object.defineProperty(createdEvent, 'dataTransfer', { value: dataTransfer });
return createdEvent;
};

fireEvent(startDayChild, createDragEventOnChild('dragStart', startDayChild));
fireEvent(startDayChild, createDragEventOnChild('dragLeave', startDayChild));
fireEvent(endDayChild, createDragEventOnChild('dragEnter', endDayChild));
fireEvent(endDayChild, createDragEventOnChild('dragOver', endDayChild));
fireEvent(endDayChild, createDragEventOnChild('drop', endDayChild));
fireEvent(endDayChild, createDragEventOnChild('dragEnd', endDayChild));

expect(onChange.callCount).to.equal(1);
expect(onChange.lastCall.args[0][0]).toEqualDateTime(initialValue[0]);
expect(onChange.lastCall.args[0][1]).toEqualDateTime(new Date(2018, 0, 29));
});

it('should not initiate drag on non-draggable dates', () => {
const onChange = spy();
render(
<DateRangeCalendar
onChange={onChange}
defaultValue={[adapterToUse.date('2018-01-10'), adapterToUse.date('2018-01-20')]}
/>,
);

// Try to drag from a non-selected (non-endpoint) date
const middleDay = getPickerDay('15');
const targetDay = getPickerDay('25');

executeDateDrag(middleDay, targetDay);

// No change should occur since middle day is not draggable
expect(onChange.callCount).to.equal(0);
});
});
});

Expand Down
104 changes: 82 additions & 22 deletions packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
'use client';
import * as React from 'react';
import useEventCallback from '@mui/utils/useEventCallback';
import { getTarget, isHTMLElement } from '@mui/x-internals/domUtils';
import { MuiPickersAdapter, PickersTimezone, PickerValidDate } from '@mui/x-date-pickers/models';
import { PickerRangeValue } from '@mui/x-date-pickers/internals';
import { RangePosition } from '../models';
import { isEndOfRange, isStartOfRange } from '../internals/utils/date-utils';

const isEnabledButtonElement = (element: Element | null): element is HTMLButtonElement =>
isHTMLElement(element) &&
element.tagName === 'BUTTON' &&
!(element as HTMLButtonElement).disabled;

interface UseDragRangeParams {
disableDragEditing?: boolean;
adapter: MuiPickersAdapter;
Expand Down Expand Up @@ -36,35 +42,84 @@ interface UseDragRangeResponse extends UseDragRangeEvents {
draggingDatePosition: RangePosition | null;
}

/**
* Finds the closest ancestor element (or the element itself) that has the specified data attribute.
* This is needed because drag/touch events can target child elements (e.g., text spans)
* inside the button, which don't have the data attributes directly.
*/
const getClosestElementWithDataAttribute = (
element: HTMLElement | null,
dataAttribute: string,
): HTMLElement | null => {
if (!element) {
return null;
}
return element.dataset[dataAttribute] != null
? element
: element.closest<HTMLElement>(`[data-${dataAttribute}]`);
};

const resolveDateFromTarget = (
target: EventTarget,
target: EventTarget | null,
adapter: MuiPickersAdapter,
timezone: PickersTimezone,
) => {
const timestampString = (target as HTMLElement).dataset.timestamp;
if (!isHTMLElement(target)) {
return null;
}

const element = getClosestElementWithDataAttribute(target, 'timestamp');
const timestampString = element?.dataset.timestamp;
if (!timestampString) {
return null;
}
const timestamp = +timestampString;

const timestamp = Number(timestampString);
return adapter.date(new Date(timestamp).toISOString(), timezone);
};

const isSameAsDraggingDate = (event: React.DragEvent<HTMLButtonElement>) => {
const timestampString = (event.target as HTMLButtonElement).dataset.timestamp;
return timestampString === event.dataTransfer.getData('draggingDate');
const target = getTarget(event.nativeEvent);
if (!isHTMLElement(target)) {
return false;
}
const element = getClosestElementWithDataAttribute(target, 'timestamp');
return element?.dataset.timestamp === event.dataTransfer.getData('draggingDate');
};

/**
* Resolves a button element from a given element.
* Searches both upward (ancestors) and downward (children) since:
* - Touch events may target child elements inside the button (e.g., TouchRipple)
* - `elementFromPoint` may return wrapper divs containing the button
*/
const resolveButtonElement = (element: Element | null): HTMLButtonElement | null => {
if (element) {
if (element instanceof HTMLButtonElement && !element.disabled) {
return element;
}
if (element.children.length) {
return resolveButtonElement(element.children[0]);
}
if (!element) {
return null;
}
return element;

// Check if element itself is a valid button
if (isEnabledButtonElement(element)) {
return element;
}

// Search upward - element could be a child of the button (e.g., text span, TouchRipple)
const closestButton = element.closest('button');
if (isEnabledButtonElement(closestButton)) {
return closestButton;
}

// Search downward (breadth-first) - element could be a wrapper containing the button
const queue: Element[] = Array.from(element.children);
while (queue.length > 0) {
const current = queue.shift()!;
if (isEnabledButtonElement(current)) {
return current;
}
queue.push(...Array.from(current.children));
}

return null;
};

const resolveElementFromTouch = (
Expand Down Expand Up @@ -120,7 +175,7 @@ const useDragRangeEvents = ({
};

const handleDragStart = useEventCallback((event: React.DragEvent<HTMLButtonElement>) => {
const newDate = resolveDateFromTarget(event.target, adapter, timezone);
const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
if (!isElementDraggable(newDate)) {
return;
}
Expand All @@ -132,11 +187,14 @@ const useDragRangeEvents = ({
setRangeDragDay(newDate);
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
const buttonDataset = (event.target as HTMLButtonElement).dataset;
if (buttonDataset.timestamp) {
// Use currentTarget (the element the handler is attached to) rather than target
// because we need the button's dataset, not a potential child element's dataset.
const element = getClosestElementWithDataAttribute(event.currentTarget, 'timestamp');
const buttonDataset = element?.dataset;
if (buttonDataset?.timestamp) {
event.dataTransfer.setData('draggingDate', buttonDataset.timestamp);
}
if (buttonDataset.position) {
if (buttonDataset?.position) {
onDatePositionChange(buttonDataset.position as RangePosition);
}
});
Expand All @@ -163,7 +221,7 @@ const useDragRangeEvents = ({
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setRangeDragDay(resolveDateFromTarget(event.target, adapter, timezone));
setRangeDragDay(resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone));
});

const handleTouchMove = useEventCallback((event: React.TouchEvent<HTMLButtonElement>) => {
Expand All @@ -186,9 +244,11 @@ const useDragRangeEvents = ({
// on mobile we should only initialize dragging state after move is detected
setIsDragging(true);

const button = event.target as HTMLButtonElement;
const buttonDataset = button.dataset;
if (buttonDataset.position) {
// Use currentTarget (the element the handler is attached to) rather than target
// because we need the button's dataset, not a potential child element's dataset.
const element = getClosestElementWithDataAttribute(event.currentTarget, 'position');
const buttonDataset = element?.dataset;
if (buttonDataset?.position) {
onDatePositionChange(buttonDataset.position as RangePosition);
}
});
Expand Down Expand Up @@ -258,7 +318,7 @@ const useDragRangeEvents = ({
if (isSameAsDraggingDate(event)) {
return;
}
const newDate = resolveDateFromTarget(event.target, adapter, timezone);
const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
if (newDate) {
onDrop(newDate);
}
Expand Down
14 changes: 14 additions & 0 deletions packages/x-internals/src/domUtils/getTarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Returns the target element of an event, accounting for shadow DOM.
* @param event The event object.
* @returns The target element of the event.
*/
export function getTarget(event: Event) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty much a straight copy from floating ui dom utils, and the below isHTMLElement is inspired by it.

if ('composedPath' in event) {
return event.composedPath()[0] ?? event.target;
}

// TS thinks `event` is of type never as it assumes all browsers support
// `composedPath()`, but browsers without shadow DOM don't.
return (event as Event).target;
}
2 changes: 2 additions & 0 deletions packages/x-internals/src/domUtils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './getTarget';
export * from './isHTMLElement';
18 changes: 18 additions & 0 deletions packages/x-internals/src/domUtils/isHTMLElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import ownerWindow from '@mui/utils/ownerWindow';

/**
* Checks if a value is an HTMLElement, including elements from other iframes/realms.
*/
export function isHTMLElement(value: unknown): value is HTMLElement {
if (typeof window === 'undefined' || value == null) {
return false;
}
if (value instanceof HTMLElement) {
return true;
}
// Cross-realm: must be an element node (nodeType 1) from another window
return (
(value as Node).nodeType === 1 &&
value instanceof (ownerWindow(value as Node) as Window & typeof globalThis).HTMLElement
);
}
Loading