FEATURE: add progress bar to toast notifications (#26483)

This change adds a progress bar to toast notifications when autoClose is enabled (true by default).

The progress bar allows users to visually see how long is left before the notification disappears.

When hovered on desktop, the progress and autoclose timer will be paused, it will resume again once the mouse is moved away from the toast notification.
This commit is contained in:
David Battersby 2024-04-05 18:29:11 +08:00 committed by GitHub
parent 38d39b6c34
commit f75d119cd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 181 additions and 75 deletions

View File

@ -25,6 +25,27 @@ module(
assert.dom(".fk-d-default-toast__icon-container").doesNotExist(); assert.dom(".fk-d-default-toast__icon-container").doesNotExist();
}); });
test("progress bar", async function (assert) {
this.toast = new DToastInstance(this, {});
this.noop = () => {};
await render(
hbs`<DDefaultToast @data={{this.toast.options.data}} @autoClose={{true}} @onRegisterProgressBar={{this.noop}} />`
);
assert.dom(".fk-d-default-toast__progress-bar").exists();
});
test("no progress bar", async function (assert) {
this.toast = new DToastInstance(this, {});
await render(
hbs`<DDefaultToast @data={{this.toast.options.data}} @autoClose={{false}} />`
);
assert.dom(".fk-d-default-toast__progress-bar").doesNotExist();
});
test("title", async function (assert) { test("title", async function (assert) {
this.toast = new DToastInstance(this, { data: { title: "Title" } }); this.toast = new DToastInstance(this, { data: { title: "Title" } });

View File

@ -1,4 +1,5 @@
import { concat, fn, hash } from "@ember/helper"; import { concat, fn, hash } from "@ember/helper";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
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";
@ -12,6 +13,12 @@ const DDefaultToast = <template>
}} }}
...attributes ...attributes
> >
{{#if @autoClose}}
<div
class="fk-d-default-toast__progress-bar"
{{didInsert @onRegisterProgressBar}}
></div>
{{/if}}
{{#if @data.icon}} {{#if @data.icon}}
<div class="fk-d-default-toast__icon-container"> <div class="fk-d-default-toast__icon-container">
{{icon @data.icon}} {{icon @data.icon}}

View File

@ -0,0 +1,135 @@
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 concatClass from "discourse/helpers/concat-class";
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";
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 {
@tracked progressBar;
@action
registerProgressBar(element) {
this.progressBar = element;
}
<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
)
)}}
>
<@toast.options.component
@data={{@toast.options.data}}
@close={{@toast.close}}
@autoClose={{@toast.options.autoClose}}
@onRegisterProgressBar={{this.registerProgressBar}}
/>
</output>
</template>
}

View File

@ -1,64 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { registerDestructor } from "@ember/destroyable";
import { cancel } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import Modifier from "ember-modifier"; import DToast from "float-kit/components/d-toast";
import concatClass from "discourse/helpers/concat-class";
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";
class AutoCloseToast extends Modifier {
element;
close;
duration;
transitionLaterHandler;
closeLaterHandler;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, _, { close, duration }) {
this.element = element;
this.close = close;
this.duration = duration;
this.element.addEventListener("mouseenter", this.stopTimer, {
passive: true,
});
this.element.addEventListener("mouseleave", this.startTimer, {
passive: true,
});
this.startTimer();
}
@bind
startTimer() {
this.transitionLaterHandler = discourseLater(() => {
this.element.classList.add(TRANSITION_CLASS);
this.closeLaterHandler = discourseLater(() => {
this.close();
}, CSS_TRANSITION_DELAY_MS);
}, this.duration);
}
@bind
stopTimer() {
cancel(this.transitionLaterHandler);
cancel(this.closeLaterHandler);
}
cleanup() {
this.stopTimer();
this.element.removeEventListener("mouseenter", this.stopTimer);
this.element.removeEventListener("mouseleave", this.startTimer);
}
}
export default class DToasts extends Component { export default class DToasts extends Component {
@service toasts; @service toasts;
@ -66,22 +8,7 @@ export default class DToasts extends Component {
<template> <template>
<section class="fk-d-toasts"> <section class="fk-d-toasts">
{{#each this.toasts.activeToasts as |toast|}} {{#each this.toasts.activeToasts as |toast|}}
<output <DToast @toast={{toast}} />
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
)
)}}
>
<toast.options.component
@data={{toast.options.data}}
@close={{toast.close}}
/>
</output>
{{/each}} {{/each}}
</section> </section>
</template> </template>

View File

@ -70,6 +70,20 @@
min-height: 30px; min-height: 30px;
} }
&__progress-bar {
width: 100%;
height: 5px;
top: 0;
left: 0;
position: absolute;
background-color: var(--success);
transform-origin: 0 0;
}
.fk-d-default-toast:has(&__progress-bar) {
padding-top: 15px;
}
&__texts { &__texts {
min-height: 30px; min-height: 30px;
display: flex; display: flex;
@ -96,5 +110,7 @@
&__message { &__message {
display: flex; display: flex;
margin-top: 0.5rem;
color: var(--primary-high);
} }
} }