diff --git a/app/assets/javascripts/discourse/app/components/d-modal.gjs b/app/assets/javascripts/discourse/app/components/d-modal.gjs index 9a90a44cddd..150faa81298 100644 --- a/app/assets/javascripts/discourse/app/components/d-modal.gjs +++ b/app/assets/javascripts/discourse/app/components/d-modal.gjs @@ -11,34 +11,70 @@ import { and, not, or } from "truth-helpers"; import ConditionalInElement from "discourse/components/conditional-in-element"; import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; +import { disableBodyScroll } from "discourse/lib/body-scroll-lock"; +import swipe from "discourse/modifiers/swipe"; import trapTab from "discourse/modifiers/trap-tab"; +import { bind } from "discourse-common/utils/decorators"; export const CLOSE_INITIATED_BY_BUTTON = "initiatedByCloseButton"; export const CLOSE_INITIATED_BY_ESC = "initiatedByESC"; export const CLOSE_INITIATED_BY_CLICK_OUTSIDE = "initiatedByClickOut"; export const CLOSE_INITIATED_BY_MODAL_SHOW = "initiatedByModalShow"; +export const CLOSE_INITIATED_BY_SWIPE_DOWN = "initiatedBySwipeDown"; const FLASH_TYPES = ["success", "error", "warning", "info"]; +const ANIMATE_MODAL_DURATION = 250; +const MIN_SWIPE_THRESHOLD = -5; + export default class DModal extends Component { @service modal; + @service site; + @service appEvents; + @tracked wrapperElement; + @tracked animating = false; @action - setupListeners(element) { + setupModal(element) { document.documentElement.addEventListener( "keydown", this.handleDocumentKeydown ); + + this.appEvents.on( + "keyboard-visibility-change", + this.handleKeyboardVisibilityChange + ); + + if (this.site.mobileView) { + disableBodyScroll(element.querySelector(".d-modal__body")); + + this.animating = true; + element + .animate( + [{ transform: "translateY(100%)" }, { transform: "translateY(0)" }], + { duration: ANIMATE_MODAL_DURATION, easing: "ease", fill: "forwards" } + ) + .finished.then(() => { + this.animating = false; + }); + } + this.wrapperElement = element; } @action - cleanupListeners() { + cleanupModal() { document.documentElement.removeEventListener( "keydown", this.handleDocumentKeydown ); + + this.appEvents.off( + "keyboard-visibility-change", + this.handleKeyboardVisibilityChange + ); } get dismissable() { @@ -67,6 +103,43 @@ export default class DModal extends Component { return true; } + @action + async handleSwipe(state) { + if (!this.site.mobileView) { + return; + } + + if (this.animating) { + return; + } + + if (state.deltaY < 0) { + await this.#animateWrapperPosition(Math.abs(state.deltaY)); + return; + } + } + + @action + handleSwipeEnded(state) { + if (!this.site.mobileView) { + return; + } + + if (this.animating) { + // if the modal is animating we don't want to risk resetting the position + // as the user releases the swipe at the same time + return; + } + + if (state.deltaY < MIN_SWIPE_THRESHOLD) { + this.wrapperElement.querySelector( + ".d-modal__container" + ).style.transform = `translateY(${Math.abs(state.deltaY)}px)`; + + this.closeModal(CLOSE_INITIATED_BY_SWIPE_DOWN); + } + } + @action handleWrapperClick(e) { if (e.button !== 0) { @@ -77,9 +150,30 @@ export default class DModal extends Component { return; } - return this.args.closeModal?.({ - initiatedBy: CLOSE_INITIATED_BY_CLICK_OUTSIDE, - }); + return this.closeModal(CLOSE_INITIATED_BY_CLICK_OUTSIDE); + } + + @action + async closeModal(initiatedBy) { + if (!this.args.closeModal) { + return; + } + + if (this.site.mobileView) { + this.animating = true; + await this.wrapperElement.animate( + [ + // hidding first ms to avoid flicker + { visibility: "hidden", offset: 0 }, + { visibility: "visible", offset: 0.01 }, + { transform: "translateY(100%)", offset: 1 }, + ], + { duration: ANIMATE_MODAL_DURATION, fill: "forwards" } + ).finished; + this.animating = false; + } + + this.args.closeModal({ initiatedBy }); } @action @@ -90,7 +184,7 @@ export default class DModal extends Component { if (event.key === "Escape" && this.dismissable) { event.stopPropagation(); - this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_ESC }); + this.closeModal(CLOSE_INITIATED_BY_ESC); } if (event.key === "Enter" && this.shouldTriggerClickOnEnter(event)) { @@ -103,7 +197,7 @@ export default class DModal extends Component { @action handleCloseButton() { - this.args.closeModal({ initiatedBy: CLOSE_INITIATED_BY_BUTTON }); + this.closeModal(CLOSE_INITIATED_BY_BUTTON); } @action @@ -127,6 +221,22 @@ export default class DModal extends Component { }; } + @bind + handleKeyboardVisibilityChange(visible) { + if (visible) { + window.scrollTo(0, 0); + } + } + + async #animateWrapperPosition(position) { + await this.wrapperElement.animate( + [{ transform: `translateY(${position}px)` }], + { + fill: "forwards", + } + ).finished; + } +