FEATURE: add swipe up to close toast notification (#26659)
Toasts can now be dismissed by doing a swipe up on mobile. --------- Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
parent
fdff9b06a5
commit
06500fa626
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(<template><DToast @toast={{toast}} /></template>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -1,11 +1,16 @@
|
|||
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 = <template>
|
||||
export default class DDefaultToast extends Component {
|
||||
@service site;
|
||||
|
||||
<template>
|
||||
<div
|
||||
class={{concatClass
|
||||
"fk-d-default-toast"
|
||||
|
@ -45,7 +50,10 @@ const DDefaultToast = <template>
|
|||
<DButton
|
||||
@icon={{toastAction.icon}}
|
||||
@translatedLabel={{toastAction.label}}
|
||||
@action={{fn toastAction.action (hash data=@data close=@close)}}
|
||||
@action={{fn
|
||||
toastAction.action
|
||||
(hash data=@data close=@close)
|
||||
}}
|
||||
class={{toastAction.class}}
|
||||
tabindex="0"
|
||||
/>
|
||||
|
@ -58,6 +66,5 @@ const DDefaultToast = <template>
|
|||
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
|
||||
</div>
|
||||
</div>
|
||||
</template>;
|
||||
|
||||
export default DDefaultToast;
|
||||
</template>
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
<template>
|
||||
<output
|
||||
role={{if @toast.options.autoClose "status" "log"}}
|
||||
key={{@toast.id}}
|
||||
class={{concatClass "fk-d-toast" @toast.options.class}}
|
||||
{{(if
|
||||
@toast.options.autoClose
|
||||
(modifier
|
||||
AutoCloseToast
|
||||
{{autoCloseToast
|
||||
close=@toast.close
|
||||
duration=@toast.options.duration
|
||||
progressBar=this.progressBar
|
||||
)
|
||||
)}}
|
||||
enabled=@toast.options.autoClose
|
||||
}}
|
||||
{{swipe
|
||||
didSwipe=this.handleSwipe
|
||||
didEndSwipe=this.handleSwipeEnded
|
||||
enabled=this.site.mobileView
|
||||
}}
|
||||
>
|
||||
<@toast.options.component
|
||||
@data={{@toast.options.data}}
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
import { registerDestructor } from "@ember/destroyable";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import Modifier from "ember-modifier";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
|
||||
const CSS_TRANSITION_DELAY_MS = 300;
|
||||
const TRANSITION_CLASS = "-fade-out";
|
||||
|
||||
export default class AutoCloseToast extends Modifier {
|
||||
element;
|
||||
close;
|
||||
duration;
|
||||
transitionLaterHandler;
|
||||
closeLaterHandler;
|
||||
progressBar;
|
||||
progressAnimation;
|
||||
enabled;
|
||||
|
||||
constructor(owner, args) {
|
||||
super(owner, args);
|
||||
registerDestructor(this, (instance) => instance.cleanup());
|
||||
}
|
||||
|
||||
modify(element, _, { close, duration, progressBar, enabled }) {
|
||||
if (enabled === false) {
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopTimer();
|
||||
this.element.removeEventListener("mouseenter", this.stopTimer);
|
||||
this.element.removeEventListener("mouseleave", this.startTimer);
|
||||
this.progressBar = null;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue