diff --git a/package.json b/package.json index e582761d..eebd627c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.75", + "version": "0.18.76", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/components/account-switcher-dialog.js b/src/js/components/account-switcher-dialog.js index af66641b..5042448c 100644 --- a/src/js/components/account-switcher-dialog.js +++ b/src/js/components/account-switcher-dialog.js @@ -1,7 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { auth, getLoginErrorMessage } from "/js/auth.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { showToast } from "/js/toasts.js"; @@ -290,9 +291,9 @@ class AccountSwitcherDialog extends Component { if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), confirmDismiss: () => this.state.$pendingAction.get() === null, - allowUpwardStretch: true, + allowOppositeStretch: true, ignoreTouchTarget: (element) => element.closest("button") !== null, }); } diff --git a/src/js/components/add-to-lists-dialog.js b/src/js/components/add-to-lists-dialog.js index d8accb3d..da22d340 100644 --- a/src/js/components/add-to-lists-dialog.js +++ b/src/js/components/add-to-lists-dialog.js @@ -1,7 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; import { isModerationList } from "/js/dataHelpers.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -240,8 +241,8 @@ class AddToListsDialog extends Component { if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { - onClose: () => this.close(), - allowUpwardStretch: true, + onDismiss: () => this.close(), + allowOppositeStretch: true, scrollContainer: this.querySelector(".add-to-lists-dialog-rows"), ignoreTouchTarget: (element) => element.closest("button") !== null, }); diff --git a/src/js/components/animated-sidebar.js b/src/js/components/animated-sidebar.js index 79b46391..7c59b62f 100644 --- a/src/js/components/animated-sidebar.js +++ b/src/js/components/animated-sidebar.js @@ -3,6 +3,7 @@ import { Component, getChildrenFragment } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { isMobileViewport } from "/js/utils.js"; import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; class AnimatedSidebar extends Component { connectedCallback() { @@ -74,6 +75,11 @@ class AnimatedSidebar extends Component { this.scrollLock ??= scrollLocks.acquire({ target: this }); const dialog = this.querySelector("dialog.sidebar"); dialog.showModal(); + enableDragToDismiss(dialog, { + direction: "left", + allowOppositeStretch: true, + onDismiss: () => this.close(), + }); } close({ restoreScroll = true } = {}) { diff --git a/src/js/components/context-menu.js b/src/js/components/context-menu.js index 03153b17..a7b404d5 100644 --- a/src/js/components/context-menu.js +++ b/src/js/components/context-menu.js @@ -3,7 +3,8 @@ import { classnames, isMobileViewport } from "/js/utils.js"; import { Component, getChildrenFragment } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { hapticsImpactLight } from "/js/haptics.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; class ContextMenu extends Component { connectedCallback() { @@ -122,8 +123,8 @@ class ContextMenu extends Component { // Setup mobile swipe-to-dismiss enableDragToDismiss(this.querySelector(".context-menu"), { eventSource: this.querySelector(".context-menu-container"), - onClose: () => this.close(), - allowUpwardStretch: true, + onDismiss: () => this.close(), + allowOppositeStretch: true, ignoreTouchTarget: (el) => el.closest("button, a") !== null, scrollContainer: this.querySelector(".context-menu"), }); diff --git a/src/js/components/create-list-dialog.js b/src/js/components/create-list-dialog.js index e9192abd..aa6adb7c 100644 --- a/src/js/components/create-list-dialog.js +++ b/src/js/components/create-list-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; import { ImageCompressor } from "/js/imageCompressor.js"; import "/js/components/image-cropper.js"; @@ -427,7 +424,7 @@ class CreateListDialog extends Component { this.querySelector("#create-list-name")?.focus({ preventScroll: true }); enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".form-dialog-content"), ignoreTouchTarget: (el) => !!el.closest("button") || diff --git a/src/js/components/drafts-dialog.js b/src/js/components/drafts-dialog.js index 384bfc2c..7c38b818 100644 --- a/src/js/components/drafts-dialog.js +++ b/src/js/components/drafts-dialog.js @@ -2,7 +2,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; import { displayRelativeTime } from "/js/utils.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect, untrack } from "/js/signals.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; import { showToast } from "/js/toasts.js"; @@ -334,7 +335,7 @@ class DraftsDialog extends Component { if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".drafts-dialog-list"), ignoreTouchTarget: (element) => element.closest("button") !== null, disableWhenKeyboardOpen: true, diff --git a/src/js/components/edit-list-details-dialog.js b/src/js/components/edit-list-details-dialog.js index 0ee62950..4d9ca1f5 100644 --- a/src/js/components/edit-list-details-dialog.js +++ b/src/js/components/edit-list-details-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; import { ImageCompressor } from "/js/imageCompressor.js"; import "/js/components/image-cropper.js"; @@ -413,7 +410,7 @@ class EditListDetailsDialog extends Component { dialog.showModal(); enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".form-dialog-content"), ignoreTouchTarget: (el) => !!el.closest("button") || diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js index c21a598f..1583214f 100644 --- a/src/js/components/edit-profile-dialog.js +++ b/src/js/components/edit-profile-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { avatarThumbnailUrl } from "/js/dataHelpers.js"; import { classnames, graphemeCount, readFileAsDataUrl } from "/js/utils.js"; import { ImageCompressor } from "/js/imageCompressor.js"; @@ -492,7 +489,7 @@ class EditProfileDialog extends Component { dialog.showModal(); enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".form-dialog-content"), ignoreTouchTarget: (el) => !!el.closest("button") || diff --git a/src/js/components/image-alt-text-dialog.js b/src/js/components/image-alt-text-dialog.js index ebd55522..03550330 100644 --- a/src/js/components/image-alt-text-dialog.js +++ b/src/js/components/image-alt-text-dialog.js @@ -2,11 +2,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { classnames, graphemeCount } from "/js/utils.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; class ImageAltTextDialog extends Component { @@ -134,7 +131,7 @@ class ImageAltTextDialog extends Component { }); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), ignoreTouchTarget: (element) => element.closest("button, textarea") !== null, }); diff --git a/src/js/components/manage-list-members-dialog.js b/src/js/components/manage-list-members-dialog.js index 000c62be..cbd5dfa8 100644 --- a/src/js/components/manage-list-members-dialog.js +++ b/src/js/components/manage-list-members-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, SignalSet, ReactiveStore, effect } from "/js/signals.js"; import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -294,7 +291,7 @@ class ManageListMembersDialog extends Component { preventScroll: true, }); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".search-dialog-results"), ignoreTouchTarget: (element) => element.closest("button, input") !== null, }); diff --git a/src/js/components/new-chat-dialog.js b/src/js/components/new-chat-dialog.js index 9c5f8aba..1c1a6bdf 100644 --- a/src/js/components/new-chat-dialog.js +++ b/src/js/components/new-chat-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { Signal, ReactiveStore, effect } from "/js/signals.js"; import { searchIconTemplate } from "/js/templates/icons/searchIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -286,7 +283,7 @@ class NewChatDialog extends Component { preventScroll: true, }); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".search-dialog-results"), ignoreTouchTarget: (element) => element.closest("button, input") !== null, }); diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 071b63e4..61b4c2d7 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -12,11 +12,8 @@ import { import { externalLinkTemplate } from "/js/templates/externalLink.template.js"; import { confirmModal } from "/js/modals/confirm.modal.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { imageIconTemplate } from "/js/templates/icons/imageIcon.template.js"; import { emojiIconTemplate } from "/js/templates/icons/emojiIcon.template.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -1252,7 +1249,7 @@ class PostComposer extends Component { // Setup mobile swipe-to-dismiss enableDragToDismiss(dialog, { confirmDismiss: () => this.confirmClose(), - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".post-composer-scroll-area"), ignoreTouchTarget: (el) => !!el.closest("button") || diff --git a/src/js/components/post-notifications-dialog.js b/src/js/components/post-notifications-dialog.js index 1290e4b5..cf9f273a 100644 --- a/src/js/components/post-notifications-dialog.js +++ b/src/js/components/post-notifications-dialog.js @@ -1,7 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import "/js/components/toggle-switch.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -156,8 +157,8 @@ class PostNotificationsDialog extends Component { if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { - onClose: () => this.close(), - allowUpwardStretch: true, + onDismiss: () => this.close(), + allowOppositeStretch: true, ignoreTouchTarget: (element) => element.closest("button") !== null, }); } diff --git a/src/js/components/reactions-dialog.js b/src/js/components/reactions-dialog.js index 7b09f066..b010cd1c 100644 --- a/src/js/components/reactions-dialog.js +++ b/src/js/components/reactions-dialog.js @@ -2,7 +2,8 @@ import { Component } from "/js/components/component.js"; import { html, render } from "/js/lib/lit-html.js"; import { effect } from "/js/signals.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { getDisplayName, groupReactions } from "/js/dataHelpers.js"; import { closeIconTemplate } from "/js/templates/icons/closeIcon.template.js"; @@ -23,7 +24,7 @@ class ReactionsDialog extends Component { dialog.showModal(); this.scrollLock ??= scrollLocks.acquire({ target: this }); enableDragToDismiss(dialog, { - onClose: () => this._close(), + onDismiss: () => this._close(), scrollContainer: this.querySelector(".reactions-list"), ignoreTouchTarget: (element) => element.closest("button") !== null, }); diff --git a/src/js/components/report-dialog.js b/src/js/components/report-dialog.js index b8229868..a7bd56c5 100644 --- a/src/js/components/report-dialog.js +++ b/src/js/components/report-dialog.js @@ -1,11 +1,8 @@ import { html, render } from "/js/lib/lit-html.js"; import { Component } from "/js/components/component.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { kebabCase } from "/js/utils.js"; import { avatarTemplate } from "/js/templates/avatar.template.js"; import { checkIconTemplate } from "/js/templates/icons/checkIcon.template.js"; @@ -819,7 +816,7 @@ class ReportDialog extends Component { if (dialog?.open) return; dialog.showModal(); enableDragToDismiss(dialog, { - onClose: () => this.close(), + onDismiss: () => this.close(), scrollContainer: this.querySelector(".report-dialog-body"), ignoreTouchTarget: (el) => !!el.closest("button") || el.tagName === "TEXTAREA", diff --git a/src/js/dialogHelpers.js b/src/js/dialogHelpers.js index e337e86a..68695f29 100644 --- a/src/js/dialogHelpers.js +++ b/src/js/dialogHelpers.js @@ -1,4 +1,4 @@ -import { isMobileViewport } from "/js/utils.js"; +import { prefersReducedMotion } from "/js/utils.js"; function hasExitMotion(element) { if (typeof element.getAnimations !== "function") return false; @@ -36,10 +36,7 @@ function waitForExitMotion(dialog) { } function shouldAnimate(dialog) { - return ( - !window.matchMedia?.("(prefers-reduced-motion: reduce)").matches && - hasExitMotion(dialog) - ); + return !prefersReducedMotion() && hasExitMotion(dialog); } // Closes a dialog through its CSS exit animation: keeps it in the top layer @@ -63,152 +60,6 @@ export function closeWithAnimation(dialog) { return waitForExitMotion(dialog).then(finish); } -export function enableDragToDismiss( - target, - { - eventSource = target, - confirmDismiss = () => true, - onClose, - allowUpwardStretch = false, - ignoreTouchTarget = () => false, - scrollContainer = null, - disableWhenKeyboardOpen = false, - } = {}, -) { - if (!isMobileViewport()) return null; - - if (target.__dragToDismiss) { - target.__dragToDismiss.cleanup(); - } - - const DISMISS_THRESHOLD = 75; - const RESISTANCE_FACTOR = 0.6; - const SNAP_BACK_MS = 200; - const SNAP_BACK_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; - let caretRestoreTimer = null; - - const dragState = { - startY: 0, - currentY: 0, - isDragging: false, - initialHeight: 0, - canDismiss: true, - }; - - // Detect keyboard open on mobile - const KEYBOARD_THRESHOLD = 150; - const viewport = window.visualViewport; - const isKeyboardOpen = () => - disableWhenKeyboardOpen && - viewport && - window.innerHeight - viewport.height > KEYBOARD_THRESHOLD; - - const hasTextSelection = () => { - const selection = document.getSelection(); - return selection !== null && !selection.isCollapsed; - }; - - const handleTouchStart = (e) => { - if (isKeyboardOpen()) return; - if (ignoreTouchTarget(e.target)) return; - if (hasTextSelection()) return; - - clearTimeout(caretRestoreTimer); - dragState.startY = e.touches[0].clientY; - dragState.currentY = dragState.startY; - dragState.isDragging = true; - dragState.initialHeight = target.getBoundingClientRect().height; - // Only allow a downward drag to dismiss when the scrollable body is already - // at the top; otherwise this gesture belongs to the scroll area. - dragState.canDismiss = !scrollContainer || scrollContainer.scrollTop <= 0; - dragState.canStretch = - allowUpwardStretch && - (!scrollContainer || - scrollContainer.scrollHeight <= scrollContainer.clientHeight); - - target.style.transition = "none"; - }; - - const handleTouchMove = (e) => { - if (!dragState.isDragging) return; - - // A selection that appears mid-gesture (long-press) switches to text selection. - if (hasTextSelection()) { - dragState.isDragging = false; - target.style.transform = ""; - target.style.caretColor = ""; - return; - } - - dragState.currentY = e.touches[0].clientY; - const deltaY = dragState.currentY - dragState.startY; - - if (deltaY > 0 && dragState.canDismiss) { - e.preventDefault(); - const adjustedDelta = deltaY * RESISTANCE_FACTOR; - // Hide caret while dragging - target.style.caretColor = "transparent"; - target.style.transform = `translateY(${adjustedDelta}px)`; - } else if (deltaY < 0 && dragState.canStretch) { - e.preventDefault(); - const adjustedDelta = Math.abs(deltaY) * (RESISTANCE_FACTOR * 0.5); - target.style.height = `${dragState.initialHeight + adjustedDelta}px`; - } else if (scrollContainer && deltaY !== 0) { - dragState.isDragging = false; - target.style.transform = ""; - target.style.caretColor = ""; - } else { - e.preventDefault(); - } - }; - - const handleTouchEnd = async () => { - if (!dragState.isDragging) return; - - const deltaY = dragState.currentY - dragState.startY; - target.style.transition = allowUpwardStretch - ? `transform ${SNAP_BACK_MS}ms ${SNAP_BACK_EASING}, height ${SNAP_BACK_MS}ms ${SNAP_BACK_EASING}` - : `transform ${SNAP_BACK_MS}ms ${SNAP_BACK_EASING}`; - - if (deltaY > DISMISS_THRESHOLD && (await confirmDismiss())) { - target.style.transform = "translateY(100%)"; - onClose(); - } else { - target.style.transform = ""; - if (dragState.canStretch) target.style.height = ""; - caretRestoreTimer = setTimeout(() => { - target.style.caretColor = ""; - }, SNAP_BACK_MS); - } - - dragState.isDragging = false; - }; - - eventSource.addEventListener("touchstart", handleTouchStart, { - passive: false, - }); - eventSource.addEventListener("touchmove", handleTouchMove, { - passive: false, - }); - eventSource.addEventListener("touchend", handleTouchEnd); - - dragState.cleanup = () => { - clearTimeout(caretRestoreTimer); - delete target.__dragToDismiss; - eventSource.removeEventListener("touchstart", handleTouchStart); - eventSource.removeEventListener("touchmove", handleTouchMove); - eventSource.removeEventListener("touchend", handleTouchEnd); - target.style.transform = ""; - target.style.transition = ""; - target.style.height = ""; - target.style.caretColor = ""; - }; - - target.__dragToDismiss = dragState; - - return dragState; -} - // iOS Safari: dismissing the keyboard via the "Done" button leaves the // dialog's inner scroll area offset, which makes buttons unclickable // until the dialog is swiped or re-tapped. Reset scroll on text input blur. diff --git a/src/js/dragHelpers.js b/src/js/dragHelpers.js new file mode 100644 index 00000000..fca912c5 --- /dev/null +++ b/src/js/dragHelpers.js @@ -0,0 +1,297 @@ +import { isMobileViewport, prefersReducedMotion } from "/js/utils.js"; + +const VELOCITY_WINDOW_MS = 100; +const MIN_VELOCITY_SAMPLE_DT_MS = 15; +const ACTIVATION_THRESHOLD_PX = 8; + +const KEYBOARD_HEIGHT_THRESHOLD_PX = 150; +function isKeyboardOpen() { + const viewport = window.visualViewport; + return ( + !!viewport && + window.innerHeight - viewport.height > KEYBOARD_HEIGHT_THRESHOLD_PX + ); +} + +function hasTextSelection() { + const selection = document.getSelection(); + return selection !== null && !selection.isCollapsed; +} + +// Low-level touch drag tracker. Reports raw signed delta along the given axis. +function trackDrag( + target, + { + axis = "y", + eventSource = target, + ignoreTouchTarget = () => false, + scrollContainer = null, + disableWhenKeyboardOpen = false, + onStart = () => {}, + onMove = () => {}, + onEnd = () => {}, + } = {}, +) { + const primaryKey = axis === "y" ? "clientY" : "clientX"; + const otherKey = axis === "y" ? "clientX" : "clientY"; + + const state = { + tracking: false, + locked: false, + startPrimary: 0, + startOther: 0, + currentPrimary: 0, + samples: [], + }; + + const primaryDelta = () => state.currentPrimary - state.startPrimary; + + const pushSample = (coord) => { + const t = performance.now(); + state.samples.push({ coord, t }); + while ( + state.samples.length > 1 && + t - state.samples[0].t > VELOCITY_WINDOW_MS + ) { + state.samples.shift(); + } + }; + + const velocity = () => { + if (state.samples.length < 2) return 0; + const first = state.samples[0]; + const last = state.samples[state.samples.length - 1]; + const dt = last.t - first.t; + if (dt < MIN_VELOCITY_SAMPLE_DT_MS) return 0; + return (last.coord - first.coord) / dt; + }; + + const handleTouchStart = (e) => { + if (disableWhenKeyboardOpen && isKeyboardOpen()) return; + if (ignoreTouchTarget(e.target)) return; + if (hasTextSelection()) return; + if (scrollContainer && scrollContainer.scrollTop > 0) return; + + const touch = e.touches[0]; + state.tracking = true; + state.locked = false; + state.startPrimary = touch[primaryKey]; + state.startOther = touch[otherKey]; + state.currentPrimary = state.startPrimary; + state.samples = []; + pushSample(state.startPrimary); + }; + + const handleTouchMove = (e) => { + if (!state.tracking) return; + + if (hasTextSelection()) { + const wasLocked = state.locked; + state.tracking = false; + state.locked = false; + if (wasLocked) onEnd({ deltaPx: 0, velocity: 0, cancelled: true }); + return; + } + + const touch = e.touches[0]; + state.currentPrimary = touch[primaryKey]; + pushSample(state.currentPrimary); + + if (!state.locked) { + const primary = primaryDelta(); + const other = touch[otherKey] - state.startOther; + if ( + Math.abs(primary) < ACTIVATION_THRESHOLD_PX && + Math.abs(other) < ACTIVATION_THRESHOLD_PX + ) { + return; + } + if (Math.abs(primary) <= Math.abs(other)) { + state.tracking = false; + return; + } + state.locked = true; + onStart(); + } + + const consumed = onMove(primaryDelta()); + // If the caller can't consume this move (e.g. dragging up on a + // dismiss-down sheet with no stretch) and there's a scroll container, + // release the gesture back to native scroll instead of preventing default. + if (consumed === false && scrollContainer) { + state.tracking = false; + state.locked = false; + onEnd({ deltaPx: 0, velocity: 0, cancelled: true }); + return; + } + e.preventDefault(); + }; + + const handleTouchEnd = () => { + if (!state.tracking) return; + const wasLocked = state.locked; + state.tracking = false; + state.locked = false; + if (!wasLocked) return; + onEnd({ + deltaPx: primaryDelta(), + velocity: velocity(), + cancelled: false, + }); + }; + + eventSource.addEventListener("touchstart", handleTouchStart, { + passive: false, + }); + eventSource.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + eventSource.addEventListener("touchend", handleTouchEnd); + eventSource.addEventListener("touchcancel", handleTouchEnd); + + return { + cleanup() { + eventSource.removeEventListener("touchstart", handleTouchStart); + eventSource.removeEventListener("touchmove", handleTouchMove); + eventSource.removeEventListener("touchend", handleTouchEnd); + eventSource.removeEventListener("touchcancel", handleTouchEnd); + }, + }; +} + +const DEFAULT_DISMISS_THRESHOLD_PX = 75; +const DEFAULT_FLICK_VELOCITY = 0.5; +const RESISTANCE_FACTOR = 0.6; +const SNAP_BACK_MS = 200; +const SNAP_BACK_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; + +const DIRECTION_TO_AXIS_SIGN = { + down: { axis: "y", sign: 1 }, + up: { axis: "y", sign: -1 }, + right: { axis: "x", sign: 1 }, + left: { axis: "x", sign: -1 }, +}; + +// Applies the standard swipe-to-dismiss policy on top of trackDrag: resistance +// during the drag, snap-back on release below threshold, exit translation past +// the distance threshold or on a flick. +export function enableDragToDismiss( + target, + { + direction = "down", + onDismiss, + confirmDismiss = () => true, + hideCaretDuringDrag = true, + allowOppositeStretch = false, + allowOppositeTranslate = false, + scrollContainer = null, + ignoreTouchTarget = () => false, + disableWhenKeyboardOpen = false, + eventSource = target, + dismissThresholdPx = DEFAULT_DISMISS_THRESHOLD_PX, + flickVelocity = DEFAULT_FLICK_VELOCITY, + onDragStart = () => {}, + onDragEnd = () => {}, + } = {}, +) { + if (!isMobileViewport()) return null; + + if (target.__dragToDismiss) target.__dragToDismiss.cleanup(); + + const { axis, sign } = DIRECTION_TO_AXIS_SIGN[direction]; + const transformFn = axis === "y" ? "translateY" : "translateX"; + const sizeProp = axis === "y" ? "height" : "width"; + let initialSize = 0; + let caretRestoreTimer = null; + + const transitionDurationMs = () => + prefersReducedMotion() ? 0 : SNAP_BACK_MS; + + const snapBackTransition = () => { + const ms = transitionDurationMs(); + const parts = [`transform ${ms}ms ${SNAP_BACK_EASING}`]; + if (allowOppositeStretch) { + parts.push(`${sizeProp} ${ms}ms ${SNAP_BACK_EASING}`); + } + return parts.join(", "); + }; + + const clearInlineStyles = () => { + target.style.transform = ""; + if (allowOppositeStretch) target.style[sizeProp] = ""; + }; + + const handle = trackDrag(target, { + axis, + eventSource, + ignoreTouchTarget, + scrollContainer, + disableWhenKeyboardOpen, + onStart: () => { + clearTimeout(caretRestoreTimer); + target.style.transition = "none"; + initialSize = target.getBoundingClientRect()[sizeProp]; + if (hideCaretDuringDrag) target.style.caretColor = "transparent"; + onDragStart(); + }, + onMove: (deltaPx) => { + const progress = deltaPx * sign; + if (progress >= 0) { + target.style.transform = `${transformFn}(${deltaPx * RESISTANCE_FACTOR}px)`; + } else if (allowOppositeStretch) { + const stretched = + initialSize + Math.abs(deltaPx) * RESISTANCE_FACTOR * 0.5; + target.style[sizeProp] = `${stretched}px`; + } else if (allowOppositeTranslate) { + target.style.transform = `${transformFn}(${deltaPx * RESISTANCE_FACTOR * 0.5}px)`; + } else { + return false; + } + }, + onEnd: async ({ deltaPx, velocity, cancelled }) => { + if (cancelled) { + clearInlineStyles(); + if (hideCaretDuringDrag) target.style.caretColor = ""; + onDragEnd({ dismissed: false }); + return; + } + + const progressPx = deltaPx * sign; + const dismissVelocity = velocity * sign; + const shouldDismiss = + progressPx > 0 && + (progressPx > dismissThresholdPx || dismissVelocity > flickVelocity); + + if (shouldDismiss && (await confirmDismiss())) { + // Use `all` so opacity/backdrop changes triggered by onDismiss + // (e.g. a toast losing its .active class) fade in sync. + target.style.transition = `all ${transitionDurationMs()}ms ${SNAP_BACK_EASING}`; + target.style.transform = `${transformFn}(${sign * 100}%)`; + onDragEnd({ dismissed: true }); + onDismiss(); + } else { + target.style.transition = snapBackTransition(); + clearInlineStyles(); + if (hideCaretDuringDrag) { + caretRestoreTimer = setTimeout(() => { + target.style.caretColor = ""; + }, transitionDurationMs()); + } + onDragEnd({ dismissed: false }); + } + }, + }); + + const cleanup = () => { + clearTimeout(caretRestoreTimer); + delete target.__dragToDismiss; + handle.cleanup(); + target.style.transform = ""; + target.style.transition = ""; + target.style[sizeProp] = ""; + target.style.caretColor = ""; + }; + + target.__dragToDismiss = { cleanup }; + return { cleanup }; +} diff --git a/src/js/modals/modal.js b/src/js/modals/modal.js index 9f4d8615..b9ada661 100644 --- a/src/js/modals/modal.js +++ b/src/js/modals/modal.js @@ -1,6 +1,7 @@ import { render } from "/js/lib/lit-html.js"; import { scrollLocks } from "/js/scrollLocks.js"; -import { closeWithAnimation, enableDragToDismiss } from "/js/dialogHelpers.js"; +import { closeWithAnimation } from "/js/dialogHelpers.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; export class Modal { static async open(...args) { @@ -89,7 +90,7 @@ export class Modal { if (this.dragToDismiss) { const selector = this.scrollContainerSelector; enableDragToDismiss(dialog, { - onClose: () => dismiss(), + onDismiss: () => dismiss(), confirmDismiss: () => this.canDismiss(), ignoreTouchTarget: (element) => this.ignoreTouchTarget(element), scrollContainer: selector ? dialog.querySelector(selector) : null, diff --git a/src/js/toasts.js b/src/js/toasts.js index c9f2911d..bf01160c 100644 --- a/src/js/toasts.js +++ b/src/js/toasts.js @@ -1,4 +1,5 @@ import { html, render } from "/js/lib/lit-html.js"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; import { alertIconTemplate } from "/js/templates/icons/alertIcon.template.js"; import { circleCheckIconTemplate } from "/js/templates/icons/circleCheckIcon.template.js"; import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; @@ -62,9 +63,34 @@ function mountToast(toast, { timeout = 3000, onDismiss = () => {} } = {}) { activeToasts.unshift(entry); restackToasts(); toast.classList.add("active"); - if (timeout) { - timeoutId = setTimeout(dismiss, timeout); - } + let remainingMs = timeout; + let scheduledAt = null; + const scheduleAutoDismiss = () => { + if (remainingMs > 0) { + scheduledAt = performance.now(); + timeoutId = setTimeout(dismiss, remainingMs); + } + }; + const pauseAutoDismiss = () => { + if (timeoutId != null) { + remainingMs = Math.max( + 0, + remainingMs - (performance.now() - scheduledAt), + ); + clearTimeout(timeoutId); + timeoutId = null; + } + }; + enableDragToDismiss(toast, { + direction: "up", + allowOppositeTranslate: true, + onDismiss: dismiss, + onDragStart: pauseAutoDismiss, + onDragEnd: ({ dismissed }) => { + if (!dismissed) scheduleAutoDismiss(); + }, + }); + scheduleAutoDismiss(); } show(); diff --git a/src/js/utils.js b/src/js/utils.js index 04025be3..6124f0c7 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -46,6 +46,8 @@ export const isTouchDevice = () => navigator.maxTouchPoints > 0; export const canHover = () => window.matchMedia("(hover: hover)").matches; export const hasKeyboardInput = () => window.matchMedia("(hover: hover) and (pointer: fine)").matches; +export const prefersReducedMotion = () => + !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; export const isSafari = () => /^((?!chrome|android).)*safari/i.test(navigator.userAgent); export const isAndroid = () => /android/i.test(navigator.userAgent); diff --git a/tests/e2e/specs/concerns/dragToDismiss.test.js b/tests/e2e/specs/concerns/dragToDismiss.test.js index 1a622caf..f8389f35 100644 --- a/tests/e2e/specs/concerns/dragToDismiss.test.js +++ b/tests/e2e/specs/concerns/dragToDismiss.test.js @@ -11,63 +11,65 @@ test.use({ // Dispatches a touchstart→touchmove→touchend sequence on eventSourceSelector. // startTouchTarget optionally specifies a child element for touchstart (to test ignoreTouchTarget). +// startX/endX default to the horizontal center of the event source; startY/endY to the vertical center. async function drag( page, - { eventSourceSelector, startTouchTargetSelector, startY, endY }, + { eventSourceSelector, startTouchTargetSelector, startX, endX, startY, endY }, ) { await page.evaluate( - ({ eventSourceSelector, startTouchTargetSelector, startY, endY }) => { + ({ + eventSourceSelector, + startTouchTargetSelector, + startX, + endX, + startY, + endY, + }) => { const eventSource = document.querySelector(eventSourceSelector); const startTarget = startTouchTargetSelector ? document.querySelector(startTouchTargetSelector) : eventSource; const rect = eventSource.getBoundingClientRect(); - const clientX = rect.left + rect.width / 2; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const sx = startX ?? centerX; + const ex = endX ?? sx; + const sy = startY ?? centerY; + const ey = endY ?? sy; + + const touchAt = (clientX, clientY) => + new Touch({ identifier: 1, target: startTarget, clientX, clientY }); startTarget.dispatchEvent( new TouchEvent("touchstart", { - touches: [ - new Touch({ - identifier: 1, - target: startTarget, - clientX, - clientY: startY, - }), - ], + touches: [touchAt(sx, sy)], bubbles: true, cancelable: true, }), ); eventSource.dispatchEvent( new TouchEvent("touchmove", { - touches: [ - new Touch({ - identifier: 1, - target: startTarget, - clientX, - clientY: endY, - }), - ], + touches: [touchAt(ex, ey)], bubbles: true, cancelable: true, }), ); eventSource.dispatchEvent( new TouchEvent("touchend", { - changedTouches: [ - new Touch({ - identifier: 1, - target: startTarget, - clientX, - clientY: endY, - }), - ], + changedTouches: [touchAt(ex, ey)], bubbles: true, cancelable: true, }), ); }, - { eventSourceSelector, startTouchTargetSelector, startY, endY }, + { + eventSourceSelector, + startTouchTargetSelector, + startX, + endX, + startY, + endY, + }, ); } @@ -593,4 +595,99 @@ test.describe("Drag-to-dismiss", () => { await expect(dialog).toBeVisible(); }); }); + + test.describe("sidebar (swipe left)", () => { + async function openSidebar(page) { + await setupFeedWithPost(page); + await page.locator('[data-testid="menu-button"]').click(); + const sidebar = page.locator("animated-sidebar .sidebar"); + await expect(sidebar).toBeVisible({ timeout: 5000 }); + return sidebar; + } + + test("dragging left past threshold dismisses it", async ({ page }) => { + const sidebar = await openSidebar(page); + await drag(page, { + eventSourceSelector: "animated-sidebar .sidebar", + startX: 250, + endX: 50, + }); + await expect(sidebar).not.toHaveJSProperty("open", true, { + timeout: 2000, + }); + }); + + test("dragging left below threshold snaps back", async ({ page }) => { + const sidebar = await openSidebar(page); + await drag(page, { + eventSourceSelector: "animated-sidebar .sidebar", + startX: 250, + endX: 220, + }); + await expect(sidebar).toHaveJSProperty("open", true); + }); + + test("dragging right (opposite direction) does not dismiss", async ({ + page, + }) => { + const sidebar = await openSidebar(page); + await drag(page, { + eventSourceSelector: "animated-sidebar .sidebar", + startX: 100, + endX: 300, + }); + await expect(sidebar).toHaveJSProperty("open", true); + }); + }); + + test.describe("toast (swipe up)", () => { + async function showToast(page, options = {}) { + await setupFeedWithPost(page); + await page.evaluate( + async ({ message, timeout }) => { + const { showToast } = await import("/js/toasts.js"); + showToast(message, { timeout }); + }, + { message: "Test toast", timeout: options.timeout ?? 10000 }, + ); + const toast = page.locator('[data-testid="toast"].active'); + await expect(toast).toBeVisible({ timeout: 2000 }); + return toast; + } + + test("dragging up past threshold dismisses it", async ({ page }) => { + await showToast(page); + await drag(page, { + eventSourceSelector: '[data-testid="toast"]', + startY: 150, + endY: 20, + }); + await expect(page.locator('[data-testid="toast"].active')).toHaveCount( + 0, + { timeout: 2000 }, + ); + }); + + test("dragging up below threshold snaps back", async ({ page }) => { + const toast = await showToast(page); + await drag(page, { + eventSourceSelector: '[data-testid="toast"]', + startY: 150, + endY: 120, + }); + await expect(toast).toBeVisible(); + }); + + test("dragging down (opposite direction) does not dismiss", async ({ + page, + }) => { + const toast = await showToast(page); + await drag(page, { + eventSourceSelector: '[data-testid="toast"]', + startY: 100, + endY: 250, + }); + await expect(toast).toBeVisible(); + }); + }); }); diff --git a/tests/unit/specs/dialogHelpers.test.js b/tests/unit/specs/dialogHelpers.test.js index e2aef0bb..e99e494d 100644 --- a/tests/unit/specs/dialogHelpers.test.js +++ b/tests/unit/specs/dialogHelpers.test.js @@ -1,26 +1,6 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { - closeWithAnimation, - enableDragToDismiss, - resetScrollOnBlur, -} from "/js/dialogHelpers.js"; -import { wait } from "/js/utils.js"; - -function pressEvent( - type, - { clientX = 0, clientY = 0, touch = false, button = 0 } = {}, -) { - const event = new Event(type, { bubbles: true, cancelable: true }); - if (touch) { - event.touches = [{ clientX, clientY }]; - } else { - event.clientX = clientX; - event.clientY = clientY; - event.button = button; - } - return event; -} +import { closeWithAnimation, resetScrollOnBlur } from "/js/dialogHelpers.js"; describe("closeWithAnimation", () => { let dialog; @@ -137,152 +117,6 @@ describe("closeWithAnimation", () => { }); }); -describe("enableDragToDismiss", () => { - let el; - let closeCount; - let dragState; - let originalMatchMedia; - let originalVisualViewport; - - const setKeyboardOpen = (open) => { - window.visualViewport = { - height: open ? window.innerHeight - 300 : window.innerHeight, - }; - }; - - const drag = async (deltaY) => { - el.dispatchEvent(pressEvent("touchstart", { touch: true, clientY: 100 })); - el.dispatchEvent( - pressEvent("touchmove", { touch: true, clientY: 100 + deltaY }), - ); - el.dispatchEvent(pressEvent("touchend", { touch: true })); - await wait(0); - }; - - beforeEach(() => { - originalMatchMedia = window.matchMedia; - window.matchMedia = (query) => ({ - matches: query === "(max-width: 799px)", - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - }); - originalVisualViewport = window.visualViewport; - setKeyboardOpen(false); - el = document.createElement("div"); - document.body.appendChild(el); - closeCount = 0; - dragState = null; - }); - - afterEach(() => { - dragState?.cleanup(); - el.remove(); - window.matchMedia = originalMatchMedia; - window.visualViewport = originalVisualViewport; - }); - - it("returns null on non-mobile viewports", () => { - window.matchMedia = originalMatchMedia; - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - assert.deepEqual(dragState, null); - }); - - it("dismisses on a downward drag past the threshold", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(150); - assert.deepEqual(closeCount, 1); - }); - - it("does not dismiss on a drag below the threshold", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(50); - assert.deepEqual(closeCount, 0); - }); - - it("dismisses while the keyboard is open by default", async () => { - setKeyboardOpen(true); - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(150); - assert.deepEqual(closeCount, 1); - }); - - it("ignores drags while the keyboard is open when disableWhenKeyboardOpen is true", async () => { - setKeyboardOpen(true); - dragState = enableDragToDismiss(el, { - onClose: () => closeCount++, - disableWhenKeyboardOpen: true, - }); - await drag(150); - assert.deepEqual(closeCount, 0); - assert.deepEqual(el.style.transform, ""); - }); - - it("hides the caret while the sheet is displaced", () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - el.dispatchEvent(pressEvent("touchstart", { touch: true, clientY: 100 })); - el.dispatchEvent(pressEvent("touchmove", { touch: true, clientY: 150 })); - assert.deepEqual(el.style.caretColor, "transparent"); - }); - - it("restores the caret only after the snap-back transition lands", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(50); - assert.deepEqual(el.style.caretColor, "transparent"); - await wait(300); - assert.deepEqual(el.style.caretColor, ""); - }); - - it("keeps the caret hidden through a dismiss", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(150); - assert.deepEqual(el.style.caretColor, "transparent"); - }); - - it("restores the caret on cleanup", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(150); - dragState.cleanup(); - assert.deepEqual(el.style.caretColor, ""); - }); - - describe("with text selected", () => { - beforeEach(() => { - el.textContent = "some selectable text"; - const range = document.createRange(); - range.selectNodeContents(el); - const selection = document.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - }); - - afterEach(() => { - document.getSelection().removeAllRanges(); - }); - - it("ignores drags that start while text is selected", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - await drag(150); - assert.deepEqual(closeCount, 0); - assert.deepEqual(el.style.transform, ""); - }); - - it("abandons a drag when a selection appears mid-gesture", async () => { - dragState = enableDragToDismiss(el, { onClose: () => closeCount++ }); - document.getSelection().removeAllRanges(); - el.dispatchEvent(pressEvent("touchstart", { touch: true, clientY: 100 })); - const range = document.createRange(); - range.selectNodeContents(el); - document.getSelection().addRange(range); - el.dispatchEvent(pressEvent("touchmove", { touch: true, clientY: 250 })); - el.dispatchEvent(pressEvent("touchend", { touch: true })); - await wait(0); - assert.deepEqual(closeCount, 0); - assert.deepEqual(el.style.transform, ""); - }); - }); -}); - describe("resetScrollOnBlur", () => { let dialog; let scrollArea; diff --git a/tests/unit/specs/dragHelpers.test.js b/tests/unit/specs/dragHelpers.test.js new file mode 100644 index 00000000..113e88d8 --- /dev/null +++ b/tests/unit/specs/dragHelpers.test.js @@ -0,0 +1,282 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { enableDragToDismiss } from "/js/dragHelpers.js"; +import { wait } from "/js/utils.js"; +function touchEvent(type, { clientX = 0, clientY = 0 } = {}) { + const event = new Event(type, { bubbles: true, cancelable: true }); + event.touches = [{ clientX, clientY }]; + return event; +} +describe("enableDragToDismiss", () => { + let el; + let closeCount; + let handle; + let originalMatchMedia; + beforeEach(() => { + originalMatchMedia = window.matchMedia; + window.matchMedia = (query) => ({ + matches: query === "(max-width: 799px)", + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }); + el = document.createElement("div"); + document.body.appendChild(el); + closeCount = 0; + handle = null; + }); + afterEach(() => { + handle?.cleanup(); + el.remove(); + window.matchMedia = originalMatchMedia; + }); + const dragTouch = async (path) => { + const [start, ...rest] = path; + el.dispatchEvent(touchEvent("touchstart", start)); + for (const point of rest) { + el.dispatchEvent(touchEvent("touchmove", point)); + if (point.pause) await wait(point.pause); + } + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + }; + describe("direction: up (toast-like)", () => { + beforeEach(() => { + handle = enableDragToDismiss(el, { + direction: "up", + onDismiss: () => closeCount++, + }); + }); + it("dismisses on an upward drag past the threshold", async () => { + await dragTouch([{ clientY: 200 }, { clientY: 50 }]); + assert.equal(closeCount, 1); + }); + it("ignores a downward drag", async () => { + await dragTouch([{ clientY: 100 }, { clientY: 250 }]); + assert.equal(closeCount, 0); + }); + it("exits with an upward translate", async () => { + await dragTouch([{ clientY: 200 }, { clientY: 50 }]); + assert.match(el.style.transform, /translateY\(-100%\)/); + }); + }); + describe("allowOppositeTranslate", () => { + it("translates in the opposite direction without changing height", () => { + handle = enableDragToDismiss(el, { + direction: "up", + onDismiss: () => closeCount++, + allowOppositeTranslate: true, + }); + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 160 })); + // direction "up" + 60px down finger movement → opposite translate 18px down + assert.match(el.style.transform, /translateY\(18px\)/); + assert.equal(el.style.height, ""); + }); + it("still dismisses on a drag in the dismiss direction", async () => { + handle = enableDragToDismiss(el, { + direction: "up", + onDismiss: () => closeCount++, + allowOppositeTranslate: true, + }); + await dragTouch([{ clientY: 200 }, { clientY: 50 }]); + assert.equal(closeCount, 1); + }); + it("snaps back to the origin on release", async () => { + handle = enableDragToDismiss(el, { + direction: "up", + onDismiss: () => closeCount++, + allowOppositeTranslate: true, + }); + await dragTouch([{ clientY: 100 }, { clientY: 160 }]); + assert.equal(el.style.transform, ""); + }); + }); + describe("direction: left (sidebar-like)", () => { + beforeEach(() => { + handle = enableDragToDismiss(el, { + direction: "left", + onDismiss: () => closeCount++, + }); + }); + it("dismisses on a leftward drag past the threshold", async () => { + await dragTouch([{ clientX: 200 }, { clientX: 50 }]); + assert.equal(closeCount, 1); + }); + it("ignores a rightward drag", async () => { + await dragTouch([{ clientX: 50 }, { clientX: 200 }]); + assert.equal(closeCount, 0); + }); + it("exits with a leftward translate", async () => { + await dragTouch([{ clientX: 200 }, { clientX: 50 }]); + assert.match(el.style.transform, /translateX\(-100%\)/); + }); + it("does not lock onto a mostly-vertical drag", async () => { + // 5px left, 40px down — vertical dominates + await dragTouch([ + { clientX: 100, clientY: 100 }, + { clientX: 95, clientY: 140 }, + ]); + assert.equal(closeCount, 0); + // Never locked → no inline transform ever applied + assert.equal(el.style.transform, ""); + }); + }); + describe("flick velocity", () => { + it("dismisses on a fast flick shorter than the distance threshold", async () => { + handle = enableDragToDismiss(el, { + direction: "down", + onDismiss: () => closeCount++, + }); + // 40px in ~20ms → 2 px/ms, well above the 0.5 px/ms flick threshold. + el.dispatchEvent(touchEvent("touchstart", { clientY: 0 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 20 })); + await wait(25); + el.dispatchEvent(touchEvent("touchmove", { clientY: 40 })); + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + assert.equal(closeCount, 1); + }); + it("does not dismiss when the flick moves in the opposite direction", async () => { + handle = enableDragToDismiss(el, { + direction: "down", + onDismiss: () => closeCount++, + }); + // Fast upward flick when dismiss direction is down. + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 80 })); + await wait(25); + el.dispatchEvent(touchEvent("touchmove", { clientY: 60 })); + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + assert.equal(closeCount, 0); + }); + }); + it("releases the gesture to native scroll on opposite-direction drags with a scroll container", async () => { + const scrollContainer = { scrollTop: 0 }; + const events = []; + const recordDefault = (e) => events.push(e.defaultPrevented); + el.addEventListener("touchmove", recordDefault); + handle = enableDragToDismiss(el, { + direction: "down", + onDismiss: () => closeCount++, + scrollContainer, + }); + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 80 })); + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + el.removeEventListener("touchmove", recordDefault); + assert.equal(closeCount, 0); + assert.deepEqual(events, [false]); + assert.equal(el.style.transform, ""); + }); + describe("default direction (down)", () => { + let originalVisualViewport; + const setKeyboardOpen = (open) => { + window.visualViewport = { + height: open ? window.innerHeight - 300 : window.innerHeight, + }; + }; + const drag = async (deltaY) => { + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 100 + deltaY })); + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + }; + beforeEach(() => { + originalVisualViewport = window.visualViewport; + setKeyboardOpen(false); + }); + afterEach(() => { + window.visualViewport = originalVisualViewport; + }); + it("returns null on non-mobile viewports", () => { + window.matchMedia = originalMatchMedia; + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + assert.deepEqual(handle, null); + }); + it("dismisses on a downward drag past the threshold", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(150); + assert.deepEqual(closeCount, 1); + }); + it("does not dismiss on a drag below the threshold", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(50); + assert.deepEqual(closeCount, 0); + }); + it("dismisses while the keyboard is open by default", async () => { + setKeyboardOpen(true); + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(150); + assert.deepEqual(closeCount, 1); + }); + it("ignores drags while the keyboard is open when disableWhenKeyboardOpen is true", async () => { + setKeyboardOpen(true); + handle = enableDragToDismiss(el, { + onDismiss: () => closeCount++, + disableWhenKeyboardOpen: true, + }); + await drag(150); + assert.deepEqual(closeCount, 0); + assert.deepEqual(el.style.transform, ""); + }); + it("hides the caret while the sheet is displaced", () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + el.dispatchEvent(touchEvent("touchmove", { clientY: 150 })); + assert.deepEqual(el.style.caretColor, "transparent"); + }); + it("restores the caret only after the snap-back transition lands", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(50); + assert.deepEqual(el.style.caretColor, "transparent"); + await wait(300); + assert.deepEqual(el.style.caretColor, ""); + }); + it("keeps the caret hidden through a dismiss", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(150); + assert.deepEqual(el.style.caretColor, "transparent"); + }); + it("restores the caret on cleanup", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(150); + handle.cleanup(); + assert.deepEqual(el.style.caretColor, ""); + }); + describe("with text selected", () => { + beforeEach(() => { + el.textContent = "some selectable text"; + const range = document.createRange(); + range.selectNodeContents(el); + const selection = document.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + afterEach(() => { + document.getSelection().removeAllRanges(); + }); + it("ignores drags that start while text is selected", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + await drag(150); + assert.deepEqual(closeCount, 0); + assert.deepEqual(el.style.transform, ""); + }); + it("abandons a drag when a selection appears mid-gesture", async () => { + handle = enableDragToDismiss(el, { onDismiss: () => closeCount++ }); + document.getSelection().removeAllRanges(); + el.dispatchEvent(touchEvent("touchstart", { clientY: 100 })); + const range = document.createRange(); + range.selectNodeContents(el); + document.getSelection().addRange(range); + el.dispatchEvent(touchEvent("touchmove", { clientY: 250 })); + el.dispatchEvent(touchEvent("touchend")); + await wait(0); + assert.deepEqual(closeCount, 0); + assert.deepEqual(el.style.transform, ""); + }); + }); + }); +});