diff --git a/app/assets/javascripts/discourse/app/modifiers/swipe.js b/app/assets/javascripts/discourse/app/modifiers/swipe.js index 7ce242ed5b0..33424aa8509 100644 --- a/app/assets/javascripts/discourse/app/modifiers/swipe.js +++ b/app/assets/javascripts/discourse/app/modifiers/swipe.js @@ -1,5 +1,9 @@ import { registerDestructor } from "@ember/destroyable"; import Modifier from "ember-modifier"; +import { + disableBodyScroll, + enableBodyScroll, +} from "discourse/lib/body-scroll-lock"; import { bind } from "discourse-common/utils/decorators"; /** @@ -74,6 +78,8 @@ export default class SwipeModifier extends Modifier { */ @bind handleTouchStart(event) { + disableBodyScroll(this.element); + this.state = { initialY: event.touches[0].clientY, initialX: event.touches[0].clientX, @@ -81,6 +87,7 @@ export default class SwipeModifier extends Modifier { deltaX: 0, direction: null, orientation: null, + element: this.element, }; this.didStartSwipeCallback?.(this.state); @@ -94,6 +101,8 @@ export default class SwipeModifier extends Modifier { */ @bind handleTouchEnd() { + enableBodyScroll(this.element); + this.didEndSwipeCallback?.(this.state); } @@ -137,5 +146,7 @@ export default class SwipeModifier extends Modifier { this.element?.removeEventListener("touchstart", this.handleTouchStart); this.element?.removeEventListener("touchmove", this.handleTouchMove); this.element?.removeEventListener("touchend", this.handleTouchEnd); + + enableBodyScroll(this.element); } } diff --git a/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-toast-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-toast-test.gjs new file mode 100644 index 00000000000..6f6699073ed --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/float-kit/d-toast-test.gjs @@ -0,0 +1,56 @@ +import { getOwner } from "@ember/application"; +import { action } from "@ember/object"; +import { render, triggerEvent } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import DToast from "float-kit/components/d-toast"; +import DToastInstance from "float-kit/lib/d-toast-instance"; + +const TOAST_SELECTOR = ".fk-d-toast"; + +function createCustomToastInstance(owner, options, newClose) { + const custom = class CustomToastInstance extends DToastInstance { + constructor() { + super(owner, options); + } + + @action + close() { + newClose.apply(this); + } + }; + + return new custom(owner, options); +} + +module("Integration | Component | FloatKit | d-toast", function (hooks) { + setupRenderingTest(hooks); + + test("swipe up to close", async function (assert) { + let closing = false; + this.site.mobileView = true; + const toast = createCustomToastInstance(getOwner(this), {}, () => { + closing = true; + }); + + await render(); + + assert.dom(TOAST_SELECTOR).exists(); + + await triggerEvent(TOAST_SELECTOR, "touchstart", { + touches: [{ clientX: 0, clientY: 0 }], + changedTouches: [{ clientX: 0, clientY: 0 }], + }); + + await triggerEvent(TOAST_SELECTOR, "touchmove", { + touches: [{ clientX: 0, clientY: -100 }], + changedTouches: [{ clientX: 0, clientY: -100 }], + }); + + await triggerEvent(TOAST_SELECTOR, "touchend", { + changedTouches: [{ clientX: 0, clientY: -100 }], + }); + + assert.ok(closing); + }); +}); diff --git a/app/assets/javascripts/float-kit/addon/components/d-default-toast.gjs b/app/assets/javascripts/float-kit/addon/components/d-default-toast.gjs index 8d89f00fa03..087fc53ac7c 100644 --- a/app/assets/javascripts/float-kit/addon/components/d-default-toast.gjs +++ b/app/assets/javascripts/float-kit/addon/components/d-default-toast.gjs @@ -1,63 +1,70 @@ +import Component from "@glimmer/component"; import { concat, fn, hash } from "@ember/helper"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { inject as service } from "@ember/service"; import { or } from "truth-helpers"; import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; import icon from "discourse-common/helpers/d-icon"; -const DDefaultToast = +} diff --git a/app/assets/javascripts/float-kit/addon/components/d-toast.gjs b/app/assets/javascripts/float-kit/addon/components/d-toast.gjs index 95e8a950ae7..7d97a3551ce 100644 --- a/app/assets/javascripts/float-kit/addon/components/d-toast.gjs +++ b/app/assets/javascripts/float-kit/addon/components/d-toast.gjs @@ -1,129 +1,95 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; -import { registerDestructor } from "@ember/destroyable"; import { action } from "@ember/object"; -import { cancel } from "@ember/runloop"; -import Modifier from "ember-modifier"; +import { service } from "@ember/service"; import { and } from "truth-helpers"; import concatClass from "discourse/helpers/concat-class"; -import discourseLater from "discourse-common/lib/later"; -import { bind } from "discourse-common/utils/decorators"; +import swipe from "discourse/modifiers/swipe"; +import autoCloseToast from "float-kit/modifiers/auto-close-toast"; -const CSS_TRANSITION_DELAY_MS = 300; -const TRANSITION_CLASS = "-fade-out"; - -class AutoCloseToast extends Modifier { - element; - close; - duration; - transitionLaterHandler; - closeLaterHandler; - progressBar; - progressAnimation; - - constructor(owner, args) { - super(owner, args); - - registerDestructor(this, (instance) => instance.cleanup()); - } - - modify(element, _, { close, duration, progressBar }) { - this.element = element; - this.close = close; - this.duration = duration; - this.timeRemaining = duration; - this.progressBar = progressBar; - this.element.addEventListener("mouseenter", this.stopTimer, { - passive: true, - }); - this.element.addEventListener("mouseleave", this.startTimer, { - passive: true, - }); - this.startTimer(); - } - - @bind - startTimer() { - this.startProgressAnimation(); - - this.transitionLaterHandler = discourseLater(() => { - this.element.classList.add(TRANSITION_CLASS); - - this.closeLaterHandler = discourseLater(() => { - this.close(); - }, CSS_TRANSITION_DELAY_MS); - }, this.timeRemaining); - } - - @bind - stopTimer() { - this.pauseProgressAnimation(); - cancel(this.transitionLaterHandler); - cancel(this.closeLaterHandler); - } - - @bind - startProgressAnimation() { - if (!this.progressBar) { - return; - } - - if (this.progressAnimation) { - this.progressAnimation.play(); - this.progressBar.style.opacity = 1; - return; - } - - this.progressAnimation = this.progressBar.animate( - { transform: `scaleX(0)` }, - { duration: this.duration, fill: "forwards" } - ); - } - - @bind - pauseProgressAnimation() { - if ( - !this.progressAnimation || - this.progressAnimation.currentTime === this.duration - ) { - return; - } - - this.progressAnimation.pause(); - this.progressBar.style.opacity = 0.5; - this.timeRemaining = this.duration - this.progressAnimation.currentTime; - } - - cleanup() { - this.stopTimer(); - this.element.removeEventListener("mouseenter", this.stopTimer); - this.element.removeEventListener("mouseleave", this.startTimer); - this.progressBar = null; - } -} +const CLOSE_SWIPE_THRESHOLD = 50; export default class DToast extends Component { + @service site; + @tracked progressBar; + animating = false; + @action registerProgressBar(element) { this.progressBar = element; } + @action + async handleSwipe(state) { + if (this.animating) { + return; + } + + if (state.deltaY < 0) { + this.#animateWrapperPosition(state.element, 0); + return; + } + + if (state.deltaY > CLOSE_SWIPE_THRESHOLD) { + this.#close(state.element); + } else { + await this.#animateWrapperPosition(state.element, state.deltaY); + } + } + + @action + async handleSwipeEnded(state) { + if (state.deltaY > CLOSE_SWIPE_THRESHOLD) { + this.#close(state.element); + } else { + await this.#animateWrapperPosition(state.element, 0); + } + } + + async #close(element) { + await this.#closeWrapperAnimation(element); + this.args.toast.close(); + } + + async #closeWrapperAnimation(element) { + this.animating = true; + + await element.animate([{ transform: "translateY(-150px)" }], { + fill: "forwards", + duration: 250, + }).finished; + + this.animating = false; + } + + async #animateWrapperPosition(element, position) { + this.animating = true; + + await element.animate([{ transform: `translateY(${-position}px)` }], { + fill: "forwards", + }).finished; + + this.animating = false; + } +