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:
David Battersby 2024-04-19 15:49:50 +08:00 committed by GitHub
parent fdff9b06a5
commit 06500fa626
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 305 additions and 158 deletions

View File

@ -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);
}
}

View File

@ -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);
});
});

View File

@ -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 = <template>
<div
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>
export default class DDefaultToast extends Component {
@service site;
{{#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}}
<template>
<div
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>
<div class="fk-d-default-toast__close-container">
<DButton class="btn-transparent" @icon="times" @action={{@close}} />
</div>
</div>
</template>;
<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>
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>
}

View File

@ -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
close=@toast.close
duration=@toast.options.duration
progressBar=this.progressBar
)
)}}
{{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}}

View File

@ -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;
}
}