DEV: toasts improvements (#24046)

- more subtle animation when showing a toast
- resumes auto close when removing the mouse from the toast
- correctly follows reduced motion
- uses output with role status as element: https://web.dev/articles/building/a-toast-component
- shows toasts inside a section element
- prevents toast to all have the same width
- fixes a bug on mobile where we would limit the width and the close button wouldn't show correctly aligned

I would prefer to have tests for this, but the conjunction of css/animations and our helper changing `discourseLater` to 0 in tests is making it quite challenging for a rather low value. We have system specs using  toasts ensuring they show when they should.
This commit is contained in:
Joffrey JAFFEUX 2023-10-23 15:23:10 +02:00 committed by GitHub
parent 2dc9c1b478
commit 552cf56afe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 85 additions and 53 deletions

View File

@ -1,27 +1,88 @@
import Component from "@glimmer/component";
import { on } from "@ember/modifier";
import { registerDestructor } from "@ember/destroyable";
import { cancel } from "@ember/runloop";
import { inject as service } from "@ember/service";
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;
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 {
@service toasts;
<template>
<div class="fk-d-toasts">
<section class="fk-d-toasts">
{{#each this.toasts.activeToasts as |toast|}}
<div
<output
role={{if toast.options.autoClose "status" "log"}}
key={{toast.id}}
class={{concatClass "fk-d-toast" toast.options.class}}
{{(if toast.options.autoClose (modifier toast.registerAutoClose))}}
{{on "mouseenter" toast.cancelAutoClose}}
{{(if
toast.options.autoClose
(modifier
AutoCloseToast close=toast.close duration=toast.options.duration
)
)}}
>
<toast.options.component
@data={{toast.options.data}}
@close={{toast.close}}
/>
</div>
</output>
{{/each}}
</div>
</section>
</template>
}

View File

@ -70,7 +70,6 @@ import DDefaultToast from "float-kit/components/d-default-toast";
export const TOAST = {
options: {
autoClose: true,
forceAutoClose: false,
duration: 3000,
component: DDefaultToast,
},

View File

@ -1,38 +1,14 @@
import { setOwner } from "@ember/application";
import { action } from "@ember/object";
import { cancel } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { modifier } from "ember-modifier";
import uniqueId from "discourse/helpers/unique-id";
import discourseLater from "discourse-common/lib/later";
import { TOAST } from "float-kit/lib/constants";
const CSS_TRANSITION_DELAY_MS = 500;
const TRANSITION_CLASS = "-fade-out";
export default class DToastInstance {
@service toasts;
options = null;
id = uniqueId();
autoCloseHandler = null;
registerAutoClose = modifier((element) => {
let innerHandler;
this.autoCloseHandler = discourseLater(() => {
element.classList.add(TRANSITION_CLASS);
innerHandler = discourseLater(() => {
this.close();
}, CSS_TRANSITION_DELAY_MS);
}, this.options.duration || TOAST.options.duration);
return () => {
cancel(innerHandler);
cancel(this.autoCloseHandler);
};
});
constructor(owner, options = {}) {
setOwner(this, owner);
@ -43,14 +19,4 @@ export default class DToastInstance {
close() {
this.toasts.close(this);
}
@action
cancelAutoClose() {
if (this.options.forceAutoClose) {
// Return early so that we do not cancel the autoClose timer.
return;
}
cancel(this.autoCloseHandler);
}
}

View File

@ -15,7 +15,6 @@ export default class Toasts extends Service {
* @param {Object} [options] - options passed to the toast component as `@toast` argument
* @param {String} [options.duration] - The duration (ms) of the toast, will be closed after this time
* @param {Boolean} [options.autoClose=true] - When true, the toast will autoClose after the duration
* @param {Boolean} [options.forceAutoClose=false] - When true, toast will still autoClose following mouseover event
* @param {ComponentClass} [options.component] - A component to render, will use `DDefaultToast` if not provided
* @param {String} [options.class] - A class added to the d-toast element
* @param {Object} [options.data] - An object which will be passed as the `@data` argument to the component

View File

@ -1,7 +1,6 @@
.fk-d-default-toast {
display: flex;
flex: 1 1 auto;
max-width: 350px;
padding: 0.5rem;
&__close-container {

View File

@ -1,16 +1,12 @@
@keyframes d-toast-opening {
0% {
transform: translateX(0px);
}
50% {
transform: translateX(-5px);
}
100% {
transform: translateX(0px);
from {
transform: translateY(var(--transform-y, 10px));
}
}
.fk-d-toasts {
--transform-y: 0;
position: fixed;
top: 5px;
right: 5px;
@ -18,6 +14,7 @@
display: flex;
flex-direction: column;
gap: 5px 0;
flex: 1 1 auto;
.mobile-view & {
left: 5px;
@ -34,7 +31,12 @@
box-shadow: var(--shadow-menu-panel);
overflow-wrap: break-word;
display: flex;
animation: d-toast-opening 0.5s ease-in-out;
animation: d-toast-opening 0.3s ease-in-out;
will-change: transform;
.desktop-view & {
margin-left: auto;
}
&:hover {
border-color: var(--primary-300);
@ -49,3 +51,9 @@
}
}
}
@media (prefers-reduced-motion: no-preference) {
.fk-d-toasts {
--transform-y: 2vh;
}
}