@mui/x-date-pickers-pro
Version:
The Pro plan edition of the MUI X Date and Time Picker components.
385 lines (365 loc) • 16.4 kB
JavaScript
'use client';
import _extends from "@babel/runtime/helpers/esm/extends";
import * as React from 'react';
import useEventCallback from '@mui/utils/useEventCallback';
import { isHTMLElement } from '@mui/x-internals/domUtils';
import { isEndOfRange, isStartOfRange } from "../internals/utils/date-utils.mjs";
/**
* Returns the element (or its closest ancestor) carrying `data-{attr}`.
* Single-word `attr` only — `dataset[attr]` (camelCase) and `.closest()`
* (kebab-case) only agree 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;
}
// Guard against malformed `data-timestamp` — `Number('abc')` is `NaN` and
// `new Date(NaN).toISOString()` throws, which would otherwise wedge the
// gesture mid-`pointerover`.
const timestamp = Number(timestampString);
if (!Number.isFinite(timestamp)) {
return null;
}
return adapter.date(new Date(timestamp).toISOString(), timezone);
};
const useDragRangeEvents = ({
adapter,
setRangeDragDay,
setIsDragging,
onDatePositionChange,
onDrop,
disableDragEditing,
dateRange,
timezone
}) => {
const isDraggingRef = React.useRef(false);
const pointerIdRef = React.useRef(null);
const sourceDateRef = React.useRef(null);
const sourcePositionRef = React.useRef(null);
const didMoveRef = React.useRef(false);
// Last cell the pointer hovered. Used to dedupe `pointerover` (which fires
// repeatedly within the same cell), and as the drop fallback for
// `pointercancel` (whose `event.target` is unreliable across browsers).
const lastHoveredCellRef = React.useRef(null);
// Each entry removes one document listener the gesture installed.
const listenerCleanupsRef = React.useRef([]);
// Outstanding capture-phase click suppressor, if any. Tracked so back-to-back
// drags can tear the prior one down before installing a new one.
const clickSuppressorRef = React.useRef(null);
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);
};
// Resets every ref the gesture mutated and removes any listeners installed
// during the gesture. Safe to call from event handlers and from unmount.
// Only reads refs, so its closure never changes — memoized for stable
// identity (referenced by `cleanup` and the unmount effect).
const clearGestureState = useEventCallback(() => {
isDraggingRef.current = false;
pointerIdRef.current = null;
sourceDateRef.current = null;
sourcePositionRef.current = null;
didMoveRef.current = false;
lastHoveredCellRef.current = null;
listenerCleanupsRef.current.forEach(teardown => teardown());
listenerCleanupsRef.current = [];
// Also tear down any in-flight click suppressor — without this, an
// unmount in the brief window between `pointerup` and the
// `setTimeout(0)` teardown leaves a capture-phase listener attached
// to `document` that swallows the next click on unrelated UI.
clickSuppressorRef.current?.();
});
const cleanup = useEventCallback(() => {
const wasActive = didMoveRef.current;
clearGestureState();
// A press without movement never activated drag UI, so skip the re-render.
if (wasActive) {
setIsDragging(false);
setRangeDragDay(null);
}
});
const installClickSuppressor = doc => {
// Tear down a prior outstanding suppressor first; back-to-back drags
// would otherwise race two listeners on the document.
clickSuppressorRef.current?.();
// suppress and teardown reference each other, so forward-declare suppress.
let suppress;
const teardown = () => {
doc.removeEventListener('click', suppress, {
capture: true
});
if (clickSuppressorRef.current === teardown) {
clickSuppressorRef.current = null;
}
};
suppress = clickEvent => {
clickEvent.preventDefault();
// `stopImmediatePropagation` (rather than just `stopPropagation`) so
// other capture-phase click listeners on `document` — analytics, focus
// traps, third-party overlays — don't observe the synthesized
// post-drag click as if the user intentionally clicked the cell.
clickEvent.stopImmediatePropagation();
teardown();
};
doc.addEventListener('click', suppress, {
capture: true
});
clickSuppressorRef.current = teardown;
// If no click ever fires (drop on a different cell, browser doesn't
// synthesize), tear the listener down so it doesn't leak.
setTimeout(teardown, 0);
};
const finalizeGesture = (event, ownerDoc, eventType) => {
const wasMoved = didMoveRef.current;
const sourceDate = sourceDateRef.current;
// For `pointerup`, the drop target is whatever element the pointer was
// actually over at release time — releasing into a gap or off the calendar
// resolves to `null` and cancels, matching native HTML5 drag.
// For `pointercancel`, `event.target` can be unreliable (browsers vary on
// whether it's the current under-pointer element or the gesture's start
// element). Fall back to the last cell the user hovered, which is the
// closest expression of their intent.
let dropOrigin;
if (eventType === 'pointercancel') {
dropOrigin = lastHoveredCellRef.current;
} else {
dropOrigin = event.target instanceof HTMLElement ? event.target : null;
}
const dropCell = getClosestElementWithDataAttribute(dropOrigin, 'timestamp');
const newDate = dropCell ? resolveDateFromTarget(dropCell, adapter, timezone) : null;
// Resolve the focusable `<button>` separately from `dropCell`. Today
// `data-timestamp` lives on the button itself, but a future custom slot
// could put it on a wrapper; in that case the cell isn't focusable and
// the disabled state lives on the inner button.
const dropButton = dropOrigin?.closest('button') ?? dropCell?.querySelector('button') ?? null;
// `shouldDisableDate` / min-max / readOnly mark the day's button as
// `disabled`. `pointerup` still lands on a disabled `<button>` in
// Chromium/WebKit, so guard explicitly — `DateRangeCalendar.handleDrop`
// doesn't re-validate the date.
const isDropDisabled = dropButton?.disabled === true;
cleanup();
if (eventType === 'pointerup' && wasMoved && dropCell) {
// The click that follows pointerup on a day cell would re-enter the
// day's selection logic and undo the drop (or, when the drag returned
// to the source, replace the range with a single-day selection).
// Swallow it. Gated on `dropCell` so a release outside the calendar
// doesn't swallow an unrelated click on the host UI.
installClickSuppressor(ownerDoc);
}
if (wasMoved && newDate && sourceDate && !isDropDisabled && !adapter.isEqual(newDate, sourceDate)) {
dropButton?.focus();
onDrop(newDate);
}
};
// `touchmove`-blocks-scroll listener. Attached eagerly in
// `handlePointerDown` for touch pointers only. Mouse/pen don't fire touch
// events. Stable at hook level so the listener identity is consistent
// across renders.
const onTouchMove = useEventCallback(touchEvent => {
if (isDraggingRef.current) {
// `touch-action: none` on the source cell isn't enough once the finger
// crosses cell boundaries.
touchEvent.preventDefault();
}
});
const handlePointerDown = useEventCallback(event => {
// Ignore secondary mouse buttons (middle = 1, right = 2). `> 0` rather
// than `!== 0` keeps the gesture permissive when `event.button` is left
// unset by a synthetic event (some test environments).
if (event.button > 0) {
return;
}
// Secondary multi-touch pointers (second finger, etc.) are explicitly
// not-primary; let them pass through without disturbing the active gesture.
if (event.isPrimary === false) {
return;
}
const newDate = resolveDateFromTarget(event.currentTarget, adapter, timezone);
if (!isElementDraggable(newDate)) {
return;
}
// A fresh primary pointerdown definitionally ends any previous gesture
// (covers pen+touch, where each pointer type has its own primary, and
// the recovery case where the original gesture's `pointerup` was lost).
if (pointerIdRef.current != null) {
cleanup();
}
// Touch implicitly captures the pointer on `pointerdown`, pinning all
// subsequent events to the source. Release so sibling cells receive their
// own `pointerover`. jsdom lacks the API; Safari 15 / some Android WebViews
// race between the `hasPointerCapture` check and the release call and throw
// `InvalidPointerId` — benign, swallow it.
try {
if (typeof event.currentTarget.hasPointerCapture === 'function' && event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
} catch {
// already released, nothing to do
}
// Note: deliberately not calling `event.preventDefault()` here. Doing so
// would suppress the synthesized click that follows pointerup, which is
// load-bearing for tap-to-advance on an endpoint cell. The iOS magnifier
// is held off by `touch-action: none` + `user-select: none` on the cell.
pointerIdRef.current = event.pointerId;
isDraggingRef.current = true;
sourceDateRef.current = newDate;
didMoveRef.current = false;
lastHoveredCellRef.current = event.currentTarget;
// Walk up rather than reading `currentTarget.dataset` directly so the
// hook keeps working if a future slot puts `data-position` on a wrapper
// around the cell (mirrors how we resolve `data-timestamp`).
const positionHost = getClosestElementWithDataAttribute(event.currentTarget, 'position');
sourcePositionRef.current = positionHost?.dataset.position ?? null;
// Use the owner document (matters for iframe-hosted pickers) for all
// document-level listeners.
const ownerDoc = event.currentTarget.ownerDocument ?? document;
// Drag UI activation is deferred until the first real move — a pure
// press on an endpoint must leave selection state alone so the click
// handler can advance it normally.
const onPointerUp = pointerEvent => {
if (pointerEvent.pointerId !== pointerIdRef.current) {
return;
}
finalizeGesture(pointerEvent, ownerDoc, 'pointerup');
};
const onPointerCancel = pointerEvent => {
if (pointerEvent.pointerId !== pointerIdRef.current) {
return;
}
// Spec intent of `pointercancel` is "UA interrupted, not the user".
// After real movement, commit the drop the user worked for; the snap-back
// would otherwise be silent and inexplicable.
finalizeGesture(pointerEvent, ownerDoc, 'pointercancel');
};
const onKeyDown = keyEvent => {
if (keyEvent.key !== 'Escape' || !didMoveRef.current) {
// No visible drag to cancel. Leave the gesture intact and let
// Escape propagate (host modal/popover can still close on it).
// A press without movement behaves identically to a tap on
// release — letting cleanup run here would only half-collapse
// the gesture without suppressing the eventual tap-to-advance.
return;
}
keyEvent.preventDefault();
cleanup();
};
ownerDoc.addEventListener('pointerup', onPointerUp);
ownerDoc.addEventListener('pointercancel', onPointerCancel);
ownerDoc.addEventListener('keydown', onKeyDown);
listenerCleanupsRef.current.push(() => ownerDoc.removeEventListener('pointerup', onPointerUp), () => ownerDoc.removeEventListener('pointercancel', onPointerCancel), () => ownerDoc.removeEventListener('keydown', onKeyDown));
// For touch input, attach the scroll-suppression listener up front rather
// than lazily on first movement. The Pointer Events spec latches
// `touch-action: none` from the source cell over the rest of the gesture,
// but real-world WebKit/Chromium versions don't always honor that —
// attaching eagerly closes that window. Mouse and pen don't fire touch
// events so they don't need it.
if (event.pointerType === 'touch') {
ownerDoc.addEventListener('touchmove', onTouchMove, {
passive: false
});
listenerCleanupsRef.current.push(() => ownerDoc.removeEventListener('touchmove', onTouchMove));
}
});
// Use `pointerover` (bubbles) rather than `pointerenter`: React's
// `onPointerEnter` is implemented on top of over/out.
const handlePointerOver = useEventCallback(event => {
if (!isDraggingRef.current || event.pointerId !== pointerIdRef.current) {
return;
}
if (lastHoveredCellRef.current === event.currentTarget) {
return;
}
const newDate = resolveDateFromTarget(event.currentTarget, adapter, timezone);
if (!newDate) {
return;
}
lastHoveredCellRef.current = event.currentTarget;
const isDifferentFromSource = sourceDateRef.current && !adapter.isEqual(newDate, sourceDateRef.current);
if (!didMoveRef.current && isDifferentFromSource) {
// A custom day slot could strip `data-position`; without it the preview
// would compute against the wrong endpoint, so abort the drag rather
// than rendering something misleading.
if (!sourcePositionRef.current) {
if (process.env.NODE_ENV !== 'production') {
console.warn('MUI X: A drag was initiated on a day cell missing `data-position`. ' + 'Drag editing requires the cell to advertise which range endpoint it represents.');
}
return;
}
// First real move: activate drag UI and tell the parent which endpoint
// is being dragged so the preview computes against the correct side.
didMoveRef.current = true;
onDatePositionChange(sourcePositionRef.current);
setIsDragging(true);
}
if (didMoveRef.current) {
setRangeDragDay(newDate);
}
});
// On unmount, clear gesture state so a remount can start fresh and any
// detached DOM nodes still referenced by gesture refs can be GC'd.
// `clearGestureState` is `useEventCallback`-stable, so the effect runs once.
React.useEffect(() => () => clearGestureState(), [clearGestureState]);
return {
onPointerDown: handlePointerDown,
onPointerOver: handlePointerOver
};
};
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,
setRangeDragDay: handleRangeDragDayChange,
disableDragEditing,
dateRange,
timezone
});
return React.useMemo(() => _extends({
isDragging,
rangeDragDay,
draggingDatePosition
}, !disableDragEditing ? dragRangeEvents : {}), [isDragging, rangeDragDay, draggingDatePosition, disableDragEditing, dragRangeEvents]);
};