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:
parent
2dc9c1b478
commit
552cf56afe
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
.fk-d-default-toast {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
max-width: 350px;
|
||||
padding: 0.5rem;
|
||||
|
||||
&__close-container {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue