UNPKG

@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
'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]); };