From 91838f46df3a52c8fc51357e06d364fc6e79c0f2 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sat, 18 Jul 2026 15:33:59 -0500 Subject: [PATCH] Update drag to dismiss logic --- package.json | 2 +- src/js/components/drafts-dialog.js | 1 + src/js/components/edit-profile-dialog.js | 1 + src/js/components/post-composer.js | 1 + src/js/modals/modal.js | 1 + src/js/utils.js | 17 +++- tests/unit/specs/utils.test.js | 111 +++++++++++++++++++++++ 7 files changed, 130 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index eacb000b..af650948 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.3", + "version": "0.18.4", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/components/drafts-dialog.js b/src/js/components/drafts-dialog.js index d781e695..ca357f95 100644 --- a/src/js/components/drafts-dialog.js +++ b/src/js/components/drafts-dialog.js @@ -336,6 +336,7 @@ class DraftsDialog extends Component { onClose: () => this.close(), scrollContainer: this.querySelector(".drafts-dialog-list"), ignoreTouchTarget: (element) => element.closest("button") !== null, + disableWhenKeyboardOpen: true, }); } diff --git a/src/js/components/edit-profile-dialog.js b/src/js/components/edit-profile-dialog.js index 18ae2940..c0ea4a9f 100644 --- a/src/js/components/edit-profile-dialog.js +++ b/src/js/components/edit-profile-dialog.js @@ -488,6 +488,7 @@ class EditProfileDialog extends Component { el.tagName === "INPUT" || el.tagName === "TEXTAREA" || !!el.closest("image-cropper"), + disableWhenKeyboardOpen: true, }); resetScrollOnBlur( diff --git a/src/js/components/post-composer.js b/src/js/components/post-composer.js index 19fb4df2..2ebd91bc 100644 --- a/src/js/components/post-composer.js +++ b/src/js/components/post-composer.js @@ -905,6 +905,7 @@ class PostComposer extends Component { el.tagName === "TEXTAREA" || el.isContentEditable || !!el.closest("[contenteditable]"), + disableWhenKeyboardOpen: true, }); resetScrollOnBlur(dialog, this.querySelector(".post-composer-scroll-area")); diff --git a/src/js/modals/modal.js b/src/js/modals/modal.js index ef7083fd..cebaa141 100644 --- a/src/js/modals/modal.js +++ b/src/js/modals/modal.js @@ -80,6 +80,7 @@ export class Modal { onClose: () => dismiss(), confirmDismiss: () => this.canDismiss(), ignoreTouchTarget: (element) => this.ignoreTouchTarget(element), + disableWhenKeyboardOpen: true, }); } }); diff --git a/src/js/utils.js b/src/js/utils.js index 12cb6711..1b8600d7 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -370,7 +370,7 @@ export function enableDragToDismiss( allowUpwardStretch = false, ignoreTouchTarget = () => false, scrollContainer = null, - disableWhenKeyboardOpen = true, + disableWhenKeyboardOpen = false, } = {}, ) { if (!isMobileViewport()) return null; @@ -381,6 +381,8 @@ export function enableDragToDismiss( const DISMISS_THRESHOLD = 75; const RESISTANCE_FACTOR = 0.6; + const SNAP_BACK_MS = 150; + let caretRestoreTimer = null; const dragState = { startY: 0, @@ -402,6 +404,7 @@ export function enableDragToDismiss( if (isKeyboardOpen()) return; if (ignoreTouchTarget(e.target)) return; + clearTimeout(caretRestoreTimer); dragState.startY = e.touches[0].clientY; dragState.currentY = dragState.startY; dragState.isDragging = true; @@ -426,6 +429,8 @@ export function enableDragToDismiss( 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(); @@ -434,6 +439,7 @@ export function enableDragToDismiss( } else if (scrollContainer && deltaY !== 0) { dragState.isDragging = false; target.style.transform = ""; + target.style.caretColor = ""; } else { e.preventDefault(); } @@ -444,8 +450,8 @@ export function enableDragToDismiss( const deltaY = dragState.currentY - dragState.startY; target.style.transition = allowUpwardStretch - ? "transform 0.15s ease-out, height 0.15s ease-out" - : "transform 0.15s ease-out"; + ? `transform ${SNAP_BACK_MS}ms ease-out, height ${SNAP_BACK_MS}ms ease-out` + : `transform ${SNAP_BACK_MS}ms ease-out`; if (deltaY > DISMISS_THRESHOLD && (await confirmDismiss())) { target.style.transform = "translateY(100%)"; @@ -453,6 +459,9 @@ export function enableDragToDismiss( } else { target.style.transform = ""; if (dragState.canStretch) target.style.height = ""; + caretRestoreTimer = setTimeout(() => { + target.style.caretColor = ""; + }, SNAP_BACK_MS); } dragState.isDragging = false; @@ -467,6 +476,7 @@ export function enableDragToDismiss( eventSource.addEventListener("touchend", handleTouchEnd); dragState.cleanup = () => { + clearTimeout(caretRestoreTimer); delete target.__dragToDismiss; eventSource.removeEventListener("touchstart", handleTouchStart); eventSource.removeEventListener("touchmove", handleTouchMove); @@ -474,6 +484,7 @@ export function enableDragToDismiss( target.style.transform = ""; target.style.transition = ""; target.style.height = ""; + target.style.caretColor = ""; }; target.__dragToDismiss = dragState; diff --git a/tests/unit/specs/utils.test.js b/tests/unit/specs/utils.test.js index 46bc5e89..7c0c2a20 100644 --- a/tests/unit/specs/utils.test.js +++ b/tests/unit/specs/utils.test.js @@ -22,6 +22,7 @@ import { withTimeout, wait, enableLongPress, + enableDragToDismiss, TimeoutError, debounce, resetScrollOnBlur, @@ -1060,6 +1061,116 @@ describe("enableLongPress - context menu", () => { }); }); +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(200); + 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("debounce", () => { let originalSetTimeout; -- 2.51.2