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 { registerDestructor } from "@ember/destroyable";
|
||||||
import Modifier from "ember-modifier";
|
import Modifier from "ember-modifier";
|
||||||
|
import {
|
||||||
|
disableBodyScroll,
|
||||||
|
enableBodyScroll,
|
||||||
|
} from "discourse/lib/body-scroll-lock";
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
import { bind } from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -74,6 +78,8 @@ export default class SwipeModifier extends Modifier {
|
||||||
*/
|
*/
|
||||||
@bind
|
@bind
|
||||||
handleTouchStart(event) {
|
handleTouchStart(event) {
|
||||||
|
disableBodyScroll(this.element);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
initialY: event.touches[0].clientY,
|
initialY: event.touches[0].clientY,
|
||||||
initialX: event.touches[0].clientX,
|
initialX: event.touches[0].clientX,
|
||||||
|
@ -81,6 +87,7 @@ export default class SwipeModifier extends Modifier {
|
||||||
deltaX: 0,
|
deltaX: 0,
|
||||||
direction: null,
|
direction: null,
|
||||||
orientation: null,
|
orientation: null,
|
||||||
|
element: this.element,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.didStartSwipeCallback?.(this.state);
|
this.didStartSwipeCallback?.(this.state);
|
||||||
|
@ -94,6 +101,8 @@ export default class SwipeModifier extends Modifier {
|
||||||
*/
|
*/
|
||||||
@bind
|
@bind
|
||||||
handleTouchEnd() {
|
handleTouchEnd() {
|
||||||
|
enableBodyScroll(this.element);
|
||||||
|
|
||||||
this.didEndSwipeCallback?.(this.state);
|
this.didEndSwipeCallback?.(this.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -137,5 +146,7 @@ export default class SwipeModifier extends Modifier {
|
||||||
this.element?.removeEventListener("touchstart", this.handleTouchStart);
|
this.element?.removeEventListener("touchstart", this.handleTouchStart);
|
||||||
this.element?.removeEventListener("touchmove", this.handleTouchMove);
|
this.element?.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
this.element?.removeEventListener("touchend", this.handleTouchEnd);
|
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,63 +1,70 @@
|
||||||
|
import Component from "@glimmer/component";
|
||||||
import { concat, fn, hash } from "@ember/helper";
|
import { concat, fn, hash } from "@ember/helper";
|
||||||
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||||
|
import { inject as service } from "@ember/service";
|
||||||
import { or } from "truth-helpers";
|
import { or } from "truth-helpers";
|
||||||
import DButton from "discourse/components/d-button";
|
import DButton from "discourse/components/d-button";
|
||||||
import concatClass from "discourse/helpers/concat-class";
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import icon from "discourse-common/helpers/d-icon";
|
import icon from "discourse-common/helpers/d-icon";
|
||||||
|
|
||||||
const DDefaultToast = <template>
|
export default class DDefaultToast extends Component {
|
||||||
<div
|
@service site;
|
||||||
class={{concatClass
|
|
||||||
"fk-d-default-toast"
|
|
||||||
(concat "-" (or @data.theme "default"))
|
|
||||||
}}
|
|
||||||
...attributes
|
|
||||||
>
|
|
||||||
{{#if @showProgressBar}}
|
|
||||||
<div
|
|
||||||
class="fk-d-default-toast__progress-bar"
|
|
||||||
{{didInsert @onRegisterProgressBar}}
|
|
||||||
></div>
|
|
||||||
{{/if}}
|
|
||||||
{{#if @data.icon}}
|
|
||||||
<div class="fk-d-default-toast__icon-container">
|
|
||||||
{{icon @data.icon}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
<div class="fk-d-default-toast__main-container">
|
|
||||||
<div class="fk-d-default-toast__texts">
|
|
||||||
{{#if @data.title}}
|
|
||||||
<div class="fk-d-default-toast__title">
|
|
||||||
{{@data.title}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{#if @data.message}}
|
|
||||||
<div class="fk-d-default-toast__message">
|
|
||||||
{{@data.message}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{#if @data.actions}}
|
<template>
|
||||||
<div class="fk-d-default-toast__actions">
|
<div
|
||||||
{{#each @data.actions as |toastAction|}}
|
class={{concatClass
|
||||||
{{#if toastAction.action}}
|
"fk-d-default-toast"
|
||||||
<DButton
|
(concat "-" (or @data.theme "default"))
|
||||||
@icon={{toastAction.icon}}
|
}}
|
||||||
@translatedLabel={{toastAction.label}}
|
...attributes
|
||||||
@action={{fn toastAction.action (hash data=@data close=@close)}}
|
>
|
||||||
class={{toastAction.class}}
|
{{#if @showProgressBar}}
|
||||||
tabindex="0"
|
<div
|
||||||
/>
|
class="fk-d-default-toast__progress-bar"
|
||||||
{{/if}}
|
{{didInsert @onRegisterProgressBar}}
|
||||||
{{/each}}
|
></div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @data.icon}}
|
||||||
|
<div class="fk-d-default-toast__icon-container">
|
||||||
|
{{icon @data.icon}}
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
<div class="fk-d-default-toast__main-container">
|
||||||
<div class="fk-d-default-toast__close-container">
|
<div class="fk-d-default-toast__texts">
|
||||||
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
|
{{#if @data.title}}
|
||||||
</div>
|
<div class="fk-d-default-toast__title">
|
||||||
</div>
|
{{@data.title}}
|
||||||
</template>;
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if @data.message}}
|
||||||
|
<div class="fk-d-default-toast__message">
|
||||||
|
{{@data.message}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
export default DDefaultToast;
|
{{#if @data.actions}}
|
||||||
|
<div class="fk-d-default-toast__actions">
|
||||||
|
{{#each @data.actions as |toastAction|}}
|
||||||
|
{{#if toastAction.action}}
|
||||||
|
<DButton
|
||||||
|
@icon={{toastAction.icon}}
|
||||||
|
@translatedLabel={{toastAction.label}}
|
||||||
|
@action={{fn
|
||||||
|
toastAction.action
|
||||||
|
(hash data=@data close=@close)
|
||||||
|
}}
|
||||||
|
class={{toastAction.class}}
|
||||||
|
tabindex="0"
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="fk-d-default-toast__close-container">
|
||||||
|
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
|
|
|
@ -1,129 +1,95 @@
|
||||||
import Component from "@glimmer/component";
|
import Component from "@glimmer/component";
|
||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { registerDestructor } from "@ember/destroyable";
|
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { cancel } from "@ember/runloop";
|
import { service } from "@ember/service";
|
||||||
import Modifier from "ember-modifier";
|
|
||||||
import { and } from "truth-helpers";
|
import { and } from "truth-helpers";
|
||||||
import concatClass from "discourse/helpers/concat-class";
|
import concatClass from "discourse/helpers/concat-class";
|
||||||
import discourseLater from "discourse-common/lib/later";
|
import swipe from "discourse/modifiers/swipe";
|
||||||
import { bind } from "discourse-common/utils/decorators";
|
import autoCloseToast from "float-kit/modifiers/auto-close-toast";
|
||||||
|
|
||||||
const CSS_TRANSITION_DELAY_MS = 300;
|
const CLOSE_SWIPE_THRESHOLD = 50;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class DToast extends Component {
|
export default class DToast extends Component {
|
||||||
|
@service site;
|
||||||
|
|
||||||
@tracked progressBar;
|
@tracked progressBar;
|
||||||
|
|
||||||
|
animating = false;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
registerProgressBar(element) {
|
registerProgressBar(element) {
|
||||||
this.progressBar = 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>
|
<template>
|
||||||
<output
|
<output
|
||||||
role={{if @toast.options.autoClose "status" "log"}}
|
role={{if @toast.options.autoClose "status" "log"}}
|
||||||
key={{@toast.id}}
|
key={{@toast.id}}
|
||||||
class={{concatClass "fk-d-toast" @toast.options.class}}
|
class={{concatClass "fk-d-toast" @toast.options.class}}
|
||||||
{{(if
|
{{autoCloseToast
|
||||||
@toast.options.autoClose
|
close=@toast.close
|
||||||
(modifier
|
duration=@toast.options.duration
|
||||||
AutoCloseToast
|
progressBar=this.progressBar
|
||||||
close=@toast.close
|
enabled=@toast.options.autoClose
|
||||||
duration=@toast.options.duration
|
}}
|
||||||
progressBar=this.progressBar
|
{{swipe
|
||||||
)
|
didSwipe=this.handleSwipe
|
||||||
)}}
|
didEndSwipe=this.handleSwipeEnded
|
||||||
|
enabled=this.site.mobileView
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<@toast.options.component
|
<@toast.options.component
|
||||||
@data={{@toast.options.data}}
|
@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