diff --git a/docs/pages/material-ui/api/swipeable-drawer.json b/docs/pages/material-ui/api/swipeable-drawer.json index 13b2e251d53e13..08d4fd34d854d0 100644 --- a/docs/pages/material-ui/api/swipeable-drawer.json +++ b/docs/pages/material-ui/api/swipeable-drawer.json @@ -2,6 +2,10 @@ "props": { "onClose": { "type": { "name": "func" }, "required": true }, "onOpen": { "type": { "name": "func" }, "required": true }, + "allowSwipeInChildren": { + "type": { "name": "union", "description": "bool
| func" }, + "default": "false" + }, "children": { "type": { "name": "node" } }, "disableBackdropTransition": { "type": { "name": "bool" } }, "disableDiscovery": { "type": { "name": "bool" } }, diff --git a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-pt.json b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-pt.json index 8346551641de5a..a535ca929c0289 100644 --- a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-pt.json +++ b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-pt.json @@ -1,6 +1,7 @@ { "componentDescription": "", "propDescriptions": { + "allowSwipeInChildren": "Callback to determine what children of the drawer the user can use to drag open the drawer. Can be a custom function to control the behavior. Set or return true / false to allow / disallow the swipe event.", "children": "O conteúdo do componente.", "disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.", "disableDiscovery": "If true, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.", diff --git a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-zh.json b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-zh.json index c51b16599ad260..75fddd295b360e 100644 --- a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-zh.json +++ b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer-zh.json @@ -1,6 +1,7 @@ { "componentDescription": "", "propDescriptions": { + "allowSwipeInChildren": "Callback to determine what children of the drawer the user can use to drag open the drawer. Can be a custom function to control the behavior. Set or return true / false to allow / disallow the swipe event.", "children": "The content of the component.", "disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.", "disableDiscovery": "If true, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.", diff --git a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer.json b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer.json index 172c07fc084e8c..32544a759b2fcd 100644 --- a/docs/translations/api-docs/swipeable-drawer/swipeable-drawer.json +++ b/docs/translations/api-docs/swipeable-drawer/swipeable-drawer.json @@ -1,6 +1,7 @@ { "componentDescription": "", "propDescriptions": { + "allowSwipeInChildren": "If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer's children. This can be useful in scenarios where the drawer is partially visible. You can customize it further with a callback that determines which children the user can drag over to open the drawer (for example, to ignore other elements that handle touch move events, like sliders).", "children": "The content of the component.", "disableBackdropTransition": "Disable the backdrop transition. This can improve the FPS on low-end devices.", "disableDiscovery": "If true, touching the screen near the edge of the drawer will not slide in the drawer a bit to promote accidental discovery of the swipe gesture.", diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.d.ts b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.d.ts index e4ff21a53291e9..fea68f162183d7 100644 --- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.d.ts +++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.d.ts @@ -2,6 +2,21 @@ import * as React from 'react'; import { DrawerProps } from '../Drawer'; export interface SwipeableDrawerProps extends Omit { + /** + * If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer's children. + * This can be useful in scenarios where the drawer is partially visible. + * You can customize it further with a callback that determines which children the user can drag over to open the drawer + * (for example, to ignore other elements that handle touch move events, like sliders). + * + * @param {TouchEvent} event The 'touchstart' event + * @param {HTMLDivElement} swipeArea The swipe area element + * @param {HTMLDivElement} paper The drawer's paper element + * + * @default false + */ + allowSwipeInChildren?: + | boolean + | ((e: TouchEvent, swipeArea: HTMLDivElement, paper: HTMLDivElement) => boolean); /** * Disable the backdrop transition. * This can improve the FPS on low-end devices. diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js index 9acb531bbc3999..2b70adc6885027 100644 --- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js +++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.js @@ -147,6 +147,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) disableSwipeToOpen = iOS, hideBackdrop, hysteresis = 0.52, + allowSwipeInChildren = false, minFlingVelocity = 450, ModalProps: { BackdropProps, ...ModalPropsProp } = {}, onClose, @@ -301,6 +302,39 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) } }); + const startMaybeSwiping = (force = false) => { + if (!maybeSwiping) { + // on Safari Mobile, if you want to be able to have the 'click' event fired on child elements, nothing in the DOM can be changed. + // this is because Safari Mobile will not fire any mouse events (still fires touch though) if the DOM changes during mousemove. + // so do this change on first touchmove instead of touchstart + if (force || !(disableDiscovery && allowSwipeInChildren)) { + flushSync(() => { + setMaybeSwiping(true); + }); + } + + const horizontalSwipe = isHorizontal(anchor); + + if (!open && paperRef.current) { + // The ref may be null when a parent component updates while swiping. + setPosition( + getMaxTranslate(horizontalSwipe, paperRef.current) + + (disableDiscovery ? 15 : -DRAG_STARTED_SIGNAL), + { + changeTransition: false, + }, + ); + } + + swipeInstance.current.velocity = 0; + swipeInstance.current.lastTime = null; + swipeInstance.current.lastTranslate = null; + swipeInstance.current.paperHit = false; + + touchDetected.current = true; + } + }; + const handleBodyTouchMove = useEventCallback((nativeEvent) => { // the ref may be null when a parent component updates while swiping if (!paperRef.current || !touchDetected.current) { @@ -312,6 +346,8 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) return; } + startMaybeSwiping(true); + const anchorRtl = getAnchor(theme, anchor); const horizontalSwipe = isHorizontal(anchor); @@ -477,7 +513,20 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) ); if (!open) { - if (disableSwipeToOpen || nativeEvent.target !== swipeAreaRef.current) { + // logic for if swipe should be ignored: + // if disableSwipeToOpen + // if target != swipeArea, and target is not a child of paper ref + // if is a child of paper ref, and `allowSwipeInChildren` does not allow it + if ( + disableSwipeToOpen || + !( + nativeEvent.target === swipeAreaRef.current || + (paperRef.current?.contains(nativeEvent.target) && + (typeof allowSwipeInChildren === 'function' + ? allowSwipeInChildren(nativeEvent, swipeAreaRef.current, paperRef.current) + : allowSwipeInChildren)) + ) + ) { return; } if (horizontalSwipe) { @@ -494,27 +543,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) swipeInstance.current.startX = currentX; swipeInstance.current.startY = currentY; - flushSync(() => { - setMaybeSwiping(true); - }); - - if (!open && paperRef.current) { - // The ref may be null when a parent component updates while swiping. - setPosition( - getMaxTranslate(horizontalSwipe, paperRef.current) + - (disableDiscovery ? 15 : -DRAG_STARTED_SIGNAL), - { - changeTransition: false, - }, - ); - } - - swipeInstance.current.velocity = 0; - swipeInstance.current.lastTime = null; - swipeInstance.current.lastTranslate = null; - swipeInstance.current.paperHit = false; - - touchDetected.current = true; + startMaybeSwiping(); }); React.useEffect(() => { @@ -574,7 +603,7 @@ const SwipeableDrawer = React.forwardRef(function SwipeableDrawer(inProps, ref) PaperProps={{ ...PaperProps, style: { - pointerEvents: variant === 'temporary' && !open ? 'none' : '', + pointerEvents: variant === 'temporary' && !open && !allowSwipeInChildren ? 'none' : '', ...PaperProps.style, }, ref: handleRef, @@ -604,6 +633,19 @@ SwipeableDrawer.propTypes /* remove-proptypes */ = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the d.ts file and run "yarn proptypes" | // ---------------------------------------------------------------------- + /** + * If set to true, the swipe event will open the drawer even if the user begins the swipe on one of the drawer's children. + * This can be useful in scenarios where the drawer is partially visible. + * You can customize it further with a callback that determines which children the user can drag over to open the drawer + * (for example, to ignore other elements that handle touch move events, like sliders). + * + * @param {TouchEvent} event The 'touchstart' event + * @param {HTMLDivElement} swipeArea The swipe area element + * @param {HTMLDivElement} paper The drawer's paper element + * + * @default false + */ + allowSwipeInChildren: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]), /** * @ignore */ diff --git a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.test.js b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.test.js index ebaaa79a160700..0f5584684accd3 100644 --- a/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.test.js +++ b/packages/mui-material/src/SwipeableDrawer/SwipeableDrawer.test.js @@ -501,17 +501,190 @@ describe('', () => { expect(document.querySelector('[class*=PrivateSwipeArea-root]')).not.to.equal(null); }); - it('should be able to attach paper ref passed through PaperProps', () => { - const ref = React.createRef(); - render( - {}} onClose={() => {}} PaperProps={{ ref }} open> -
- , - ); - expect(ref.current).not.to.equal(null); + const openTouchesForSwipingChildren = [ + { pageX: 0, clientY: windowHeight - 20 }, + { pageX: 0, clientY: windowHeight - 60 }, + { pageX: 0, clientY: windowHeight - 180 }, + ]; + + const handleHeight = 60; + + describe('prop: allowSwipeInChildren', () => { + it('should allow swiping on children to open', () => { + const handleOpen = spy(); + render( + {}} + open={false} + swipeAreaWidth={20} + SwipeAreaProps={{ + style: { + // ensure clicks will not be grabbed by swipe area to ensure testing just this functionality + pointerEvents: 'none', + }, + }} + PaperProps={{ component: FakePaper }} + ModalProps={{ + keepMounted: true, + sx: { + transform: `translateY(${handleHeight}px) !important`, + }, + }} + > +
+
+ SwipeableDrawer +
+
+
, + ); + + const handle = screen.getAllByTestId('handle').slice(-1)[0]; + + fireEvent.touchStart(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[0] }), + ], + }); + fireEvent.touchMove(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[1] }), + ], + }); + fireEvent.touchMove(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }), + ], + }); + fireEvent.touchEnd(handle, { + changedTouches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }), + ], + }); + expect(handleOpen.callCount).to.equal(1); + }); + + it('should not allow swiping on children to open that are excluded via a function', () => { + const handleOpen = spy(); + render( + { + const elem = e.target; + // ignore touch events from .ignore &^ from swipe area + return ( + !elem.classList.contains('ignore') && + !elem.classList.contains('PrivateSwipeArea-root') + ); + }} + onOpen={handleOpen} + onClose={() => {}} + open={false} + swipeAreaWidth={20} + SwipeAreaProps={{ + style: { + // ensure clicks will not be grabbed by swipe area to ensure testing just this functionality + pointerEvents: 'none', + }, + }} + PaperProps={{ component: FakePaper }} + ModalProps={{ + keepMounted: true, + sx: { + '& > *': { + pointerEvents: 'auto', + }, + }, + }} + > +
+
+ SwipeableDrawer +
+
+
, + ); + + // should ignore the drawer touch events + const drawer = screen.getAllByTestId('drawer').slice(-1)[0]; + + fireEvent.touchStart(drawer, { + touches: [ + new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[0] }), + ], + }); + fireEvent.touchMove(drawer, { + touches: [ + new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[1] }), + ], + }); + fireEvent.touchMove(drawer, { + touches: [ + new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[2] }), + ], + }); + fireEvent.touchEnd(drawer, { + changedTouches: [ + new Touch({ identifier: 0, target: drawer, ...openTouchesForSwipingChildren[2] }), + ], + }); + expect(handleOpen.callCount).to.equal(0); + + // should allow opening the drawer via handle + const handle = screen.getAllByTestId('handle').slice(-1)[0]; + + fireEvent.touchStart(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[0] }), + ], + }); + fireEvent.touchMove(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[1] }), + ], + }); + fireEvent.touchMove(handle, { + touches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }), + ], + }); + fireEvent.touchEnd(handle, { + changedTouches: [ + new Touch({ identifier: 0, target: handle, ...openTouchesForSwipingChildren[2] }), + ], + }); + expect(handleOpen.callCount).to.equal(1); + }); }); }); + it('should be able to attach paper ref passed through PaperProps', () => { + const ref = React.createRef(); + render( + {}} onClose={() => {}} PaperProps={{ ref }} open> +
+ , + ); + expect(ref.current).not.to.equal(null); + }); + describe('disableSwipeToOpen', () => { it('should not support swipe to open if disableSwipeToOpen is set', () => { const handleOpen = spy();