UNPKG

@wix/design-system

Version:

@wix/design-system

256 lines 10.4 kB
import React, { useCallback, useEffect, useRef } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; import { classes, st } from './Drawer.st.css.js'; import { DATA_HOOKS, CONTENT_DRAG_THRESHOLD_PX } from './Drawer.constants'; import { ZIndex } from '../common/ZIndex'; const Drawer = ({ open, backdrop = true, renderBackdrop = true, resizable = true, margin = true, onClose, role = 'dialog', ariaLabelledBy, snapPoints = [1], children, dismissible = true, dataHook, zIndex = ZIndex.popover, scrollable = true, repositionInputs = true, modal = true, }) => { const contentRef = useRef(null); const handleRef = useRef(null); const restingY = useRef(0); const dragState = useRef({ isDragging: false, startY: 0, startTranslateY: 0, currentDelta: 0, }); const contentGesture = useRef({ active: false, committed: false, startX: 0, startY: 0, pointerId: -1, }); useEffect(() => { if (!open || !repositionInputs || typeof window === 'undefined' || !window.visualViewport) { return; } const viewport = window.visualViewport; const el = contentRef.current; const onViewportResize = () => { if (!el) return; const keyboardHeight = Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop); el.style.bottom = keyboardHeight > 0 ? `${keyboardHeight}px` : ''; }; viewport.addEventListener('resize', onViewportResize); return () => { viewport.removeEventListener('resize', onViewportResize); if (el) { el.style.bottom = ''; } }; }, [open, repositionInputs]); useEffect(() => { const el = contentRef.current; if (!open || !resizable || !el) return; const onTouchMove = (e) => { if (dragState.current.isDragging && e.cancelable) { e.preventDefault(); } }; el.addEventListener('touchmove', onTouchMove, { passive: false }); return () => el.removeEventListener('touchmove', onTouchMove); }, [open, resizable]); const resolveSnapPoint = (snap) => { const value = typeof snap === 'string' ? parseFloat(snap.replace('px', '')) : snap; if (value <= 1) { return value * (contentRef.current?.clientHeight ?? window.innerHeight); } return value; }; const animateToY = (fromY, toY, onDone) => { const el = contentRef.current; if (!el) return; el.style.animation = 'none'; // Check needed for JSDOM environment, since animation api isn't implemented if (typeof el.animate !== 'function') { el.style.transform = toY === 0 ? '' : `translateY(${toY}px)`; el.style.animation = ''; restingY.current = toY; onDone?.(); return; } const anim = el.animate([ { transform: `translateY(${fromY}px)` }, { transform: `translateY(${toY}px)` }, ], { duration: 300, easing: 'ease' }); anim.onfinish = () => { el.style.transform = toY === 0 ? '' : `translateY(${toY}px)`; if (onDone) { onDone(); } else { el.style.animation = ''; restingY.current = toY; } }; }; const beginDrag = (startY) => { dragState.current = { isDragging: true, startY, startTranslateY: restingY.current, currentDelta: 0, }; if (contentRef.current) { contentRef.current.style.animation = 'none'; contentRef.current.style.transition = 'none'; } }; const onHandlePointerDown = (e) => { e.currentTarget.setPointerCapture?.(e.pointerId); beginDrag(e.clientY); }; const onHandlePointerMove = (e) => { if (!dragState.current.isDragging) return; const delta = e.clientY - dragState.current.startY; dragState.current.currentDelta = delta; const newY = Math.max(0, dragState.current.startTranslateY + delta); if (contentRef.current) { contentRef.current.style.transform = `translateY(${newY}px)`; } }; const onHandlePointerUp = () => { if (!dragState.current.isDragging) return; dragState.current.isDragging = false; const el = contentRef.current; if (!el) return; const contentHeight = el.offsetHeight; const currentY = Math.max(0, dragState.current.startTranslateY + dragState.current.currentDelta); const resolvedSnaps = snapPoints .map(resolveSnapPoint) .sort((a, b) => a - b); const visibleHeight = contentHeight - currentY; const lowestSnap = resolvedSnaps[0]; if (dismissible && visibleHeight < lowestSnap * 0.5) { animateToY(currentY, contentHeight, () => onClose(false)); return; } let nearest = resolvedSnaps[0]; let minDist = Infinity; for (const snap of resolvedSnaps) { const dist = Math.abs(visibleHeight - snap); if (dist < minDist) { minDist = dist; nearest = snap; } } animateToY(currentY, Math.max(0, contentHeight - nearest)); }; const findScrolledAncestor = (target) => { let el = target; while (el) { if (el.scrollTop > 0) return el; if (el === contentRef.current) break; el = el.parentElement; } return null; }; const isAtLargestSnapPoint = () => { const el = contentRef.current; if (!el) return true; const resolvedSnaps = snapPoints .map(resolveSnapPoint) .sort((a, b) => a - b); const largest = resolvedSnaps[resolvedSnaps.length - 1]; const visibleHeight = el.offsetHeight - restingY.current; return visibleHeight >= largest - 1; }; const onContentPointerDown = (e) => { if (e.pointerType === 'mouse') return; if (handleRef.current?.contains(e.target)) return; contentGesture.current = { active: true, committed: false, startX: e.clientX, startY: e.clientY, pointerId: e.pointerId, }; }; const onContentPointerMove = (e) => { const gesture = contentGesture.current; if (!gesture.active) return; if (gesture.committed) { onHandlePointerMove(e); return; } const deltaY = e.clientY - gesture.startY; const deltaX = e.clientX - gesture.startX; if (Math.abs(deltaY) < CONTENT_DRAG_THRESHOLD_PX) return; if (Math.abs(deltaY) <= Math.abs(deltaX)) { gesture.active = false; return; } const draggingDown = deltaY > 0; const shouldDrag = draggingDown ? !findScrolledAncestor(e.target) : !isAtLargestSnapPoint(); if (!shouldDrag) { gesture.active = false; return; } gesture.committed = true; contentRef.current?.setPointerCapture?.(gesture.pointerId); beginDrag(gesture.startY); onHandlePointerMove(e); }; const onContentPointerUp = () => { const { committed } = contentGesture.current; contentGesture.current.active = false; contentGesture.current.committed = false; if (committed) { onHandlePointerUp(); } }; const handleDismiss = (e) => { e.preventDefault(); if (!dismissible) return; const el = contentRef.current; if (!el) { onClose(false); return; } animateToY(restingY.current, el.offsetHeight, () => onClose(false)); }; const lastAnimatedElRef = useRef(null); const handleContentRef = useCallback((el) => { if (el && el !== lastAnimatedElRef.current) { contentRef.current = el; lastAnimatedElRef.current = el; if (!contentRef.current) return; const snapPx = resolveSnapPoint(snapPoints[0]); const targetY = Math.max(0, el.offsetHeight - snapPx); animateToY(contentRef.current.clientHeight, targetY); } }, [snapPoints]); return (React.createElement("div", { "data-hook": dataHook }, React.createElement(Dialog.Root, { open: open, onOpenChange: onClose, modal: modal }, React.createElement(Dialog.Portal, null, renderBackdrop && (React.createElement(Dialog.Overlay, { "data-hook": DATA_HOOKS.OVERLAY, className: st(classes.overlay, { backdrop }), style: { zIndex } })), React.createElement(Dialog.Content, { ref: handleContentRef, "data-hook": DATA_HOOKS.CONTENT, "aria-modal": "true", "aria-labelledby": ariaLabelledBy, role: role, className: st(classes.content, { margin, resizable }), onInteractOutside: dismissible ? undefined : e => e.preventDefault(), onEscapeKeyDown: handleDismiss, onPointerDown: resizable ? onContentPointerDown : undefined, onPointerMove: resizable ? onContentPointerMove : undefined, onPointerUp: resizable ? onContentPointerUp : undefined, style: { zIndex, } }, React.createElement(Dialog.Title, { className: classes.title }), resizable && (React.createElement("div", { ref: handleRef, className: classes.handle, "data-hook": DATA_HOOKS.HANDLE, onPointerDown: onHandlePointerDown, onPointerMove: onHandlePointerMove, onPointerUp: onHandlePointerUp })), React.createElement("div", { className: classes.contentInner }, scrollable ? (React.createElement("div", { "data-hook": DATA_HOOKS.CONTENT_WRAPPER, className: st(classes.contentWrapper, { margin }) }, children)) : (children))))))); }; Drawer.displayName = 'Drawer'; export default Drawer; //# sourceMappingURL=Drawer.js.map