@mui/x-date-pickers-pro
Version:
The Pro plan edition of the MUI X Date and Time Picker components.
317 lines (308 loc) • 10.6 kB
JavaScript
'use client';
import _extends from "@babel/runtime/helpers/esm/extends";
import * as React from 'react';
import useEventCallback from '@mui/utils/useEventCallback';
import { getTarget, isHTMLElement } from '@mui/x-internals/domUtils';
import { isEndOfRange, isStartOfRange } from "../internals/utils/date-utils.mjs";
const isEnabledButtonElement = element => isHTMLElement(element) && element.tagName === 'BUTTON' && !element.disabled;
/**
* 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.
*
* @param element The element to start searching from.
* @param dataAttribute The data attribute name — must be a single lowercase word
* (e.g., 'timestamp', 'position') because `dataset[attr]` uses camelCase
* while `.closest()` uses kebab-case, and these only align for single-word names.
*/
const getClosestElementWithDataAttribute = (element, dataAttribute) => {
if (!element) {
return null;
}
return element.dataset[dataAttribute] != null ? element : element.closest(`[data-${dataAttribute}]`);
};
const resolveDateFromTarget = (target, adapter, timezone) => {
if (!isHTMLElement(target)) {
return null;
}
const element = getClosestElementWithDataAttribute(target, 'timestamp');
const timestampString = element?.dataset.timestamp;
if (!timestampString) {
return null;
}
const timestamp = Number(timestampString);
return adapter.date(new Date(timestamp).toISOString(), timezone);
};
const isSameAsDraggingDate = event => {
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 => {
if (!element) {
return null;
}
// 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, max 3 levels) - element could be a wrapper containing the button.
// Day cells have shallow DOM, so a small depth limit keeps this efficient.
const queue = Array.from(element.children).map(el => ({
el,
depth: 1
}));
const maxDepth = 3;
while (queue.length > 0) {
const {
el: current,
depth
} = queue.shift();
if (isEnabledButtonElement(current)) {
return current;
}
if (depth < maxDepth) {
queue.push(...Array.from(current.children).map(el => ({
el,
depth: depth + 1
})));
}
}
return null;
};
const resolveElementFromTouch = (event, ignoreTouchTarget) => {
// don't parse multi-touch result
if (event.changedTouches?.length === 1 && event.touches.length <= 1) {
const element = document.elementFromPoint(event.changedTouches[0].clientX, event.changedTouches[0].clientY);
// `elementFromPoint` could have resolved preview div or wrapping div
// might need to recursively find the nested button
const buttonElement = resolveButtonElement(element);
if (ignoreTouchTarget && buttonElement === event.changedTouches[0].target) {
return null;
}
return buttonElement;
}
return null;
};
const useDragRangeEvents = ({
adapter,
setRangeDragDay,
setIsDragging,
isDragging,
onDatePositionChange,
onDrop,
disableDragEditing,
dateRange,
timezone
}) => {
const emptyDragImgRef = React.useRef(null);
React.useEffect(() => {
// Preload the image - required for Safari support: https://stackoverflow.com/a/40923520/3303436
emptyDragImgRef.current = document.createElement('img');
emptyDragImgRef.current.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}, []);
const isElementDraggable = day => {
if (day == null) {
return false;
}
const shouldInitDragging = !disableDragEditing && !!dateRange[0] && !!dateRange[1];
const isSelectedStartDate = isStartOfRange(adapter, day, dateRange);
const isSelectedEndDate = isEndOfRange(adapter, day, dateRange);
return shouldInitDragging && (isSelectedStartDate || isSelectedEndDate);
};
const handleDragStart = useEventCallback(event => {
const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
if (!isElementDraggable(newDate)) {
return;
}
event.stopPropagation();
if (emptyDragImgRef.current) {
event.dataTransfer.setDragImage(emptyDragImgRef.current, 0, 0);
}
setRangeDragDay(newDate);
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
// 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) {
onDatePositionChange(buttonDataset.position);
}
});
const handleTouchStart = useEventCallback(event => {
const target = resolveElementFromTouch(event);
if (!target) {
return;
}
const newDate = resolveDateFromTarget(target, adapter, timezone);
if (!isElementDraggable(newDate)) {
return;
}
setRangeDragDay(newDate);
});
const handleDragEnter = useEventCallback(event => {
if (!isDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setRangeDragDay(resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone));
});
const handleTouchMove = useEventCallback(event => {
const target = resolveElementFromTouch(event);
if (!target) {
return;
}
const newDate = resolveDateFromTarget(target, adapter, timezone);
if (newDate) {
setRangeDragDay(newDate);
}
// this prevents initiating drag when user starts touchmove outside and then moves over a draggable element
const targetsAreIdentical = target === event.changedTouches[0].target;
if (!targetsAreIdentical || !isElementDraggable(newDate)) {
return;
}
// on mobile we should only initialize dragging state after move is detected
setIsDragging(true);
// 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);
}
});
const handleDragLeave = useEventCallback(event => {
if (!isDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
});
const handleDragOver = useEventCallback(event => {
if (!isDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
});
const handleTouchEnd = useEventCallback(event => {
if (!isDragging) {
return;
}
setRangeDragDay(null);
setIsDragging(false);
const target = resolveElementFromTouch(event, true);
if (!target) {
return;
}
// make sure the focused element is the element where touch ended
target.focus();
const newDate = resolveDateFromTarget(target, adapter, timezone);
if (newDate) {
onDrop(newDate);
}
});
const handleDragEnd = useEventCallback(event => {
if (!isDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
setRangeDragDay(null);
});
const handleDrop = useEventCallback(event => {
if (!isDragging) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
setRangeDragDay(null);
// make sure the focused element is the element where drop ended
event.currentTarget.focus();
if (isSameAsDraggingDate(event)) {
return;
}
const newDate = resolveDateFromTarget(getTarget(event.nativeEvent), adapter, timezone);
if (newDate) {
onDrop(newDate);
}
});
return {
onDragStart: handleDragStart,
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDragEnd: handleDragEnd,
onDrop: handleDrop,
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd
};
};
export const useDragRange = ({
disableDragEditing,
adapter,
onDatePositionChange,
onDrop,
dateRange,
timezone
}) => {
const [isDragging, setIsDragging] = React.useState(false);
const [rangeDragDay, setRangeDragDay] = React.useState(null);
const handleRangeDragDayChange = useEventCallback(newValue => {
if (!adapter.isEqual(newValue, rangeDragDay)) {
setRangeDragDay(newValue);
}
});
const draggingDatePosition = React.useMemo(() => {
const [start, end] = dateRange;
if (rangeDragDay) {
if (start && adapter.isBefore(rangeDragDay, start)) {
return 'start';
}
if (end && adapter.isAfter(rangeDragDay, end)) {
return 'end';
}
}
return null;
}, [dateRange, rangeDragDay, adapter]);
const dragRangeEvents = useDragRangeEvents({
adapter,
onDatePositionChange,
onDrop,
setIsDragging,
isDragging,
setRangeDragDay: handleRangeDragDayChange,
disableDragEditing,
dateRange,
timezone
});
return React.useMemo(() => _extends({
isDragging,
rangeDragDay,
draggingDatePosition
}, !disableDragEditing ? dragRangeEvents : {}), [isDragging, rangeDragDay, draggingDatePosition, disableDragEditing, dragRangeEvents]);
};