-
Notifications
You must be signed in to change notification settings - Fork 4
/
notification.ts
326 lines (282 loc) · 10.3 KB
/
notification.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import { animateTo, stopAnimations } from '../../internal/animate.js';
import { css, html } from 'lit';
import { customElement } from '../../internal/register-custom-element.js';
import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry.js';
import { LocalizeController } from '../../utilities/localize.js';
import { property, query } from 'lit/decorators.js';
import { waitForEvent } from '../../internal/event.js';
import { watch } from '../../internal/watch.js';
import componentStyles from '../../styles/component.styles';
import cx from 'classix';
import SolidElement from '../../internal/solid-element.js';
const toastStackDefault = Object.assign(document.createElement('div'), {
className: 'sd-toast-stack sd-toast-stack--top-right'
});
const toastStackBottomCenter = Object.assign(document.createElement('div'), {
className: 'sd-toast-stack sd-toast-stack--bottom-center'
});
/**
* @summary Alerts are used to display important messages inline or as toast notifications.
* @documentation https://solid.union-investment.com/[storybook-link]/notification
* @status stable
* @since 1.22.0
*
* @dependency sd-button
*
* @slot - The sd-notification's main content.
* @slot icon - An icon to show in the sd-notification. Works best with `<sd-icon>`.
*
* @event sd-show - Emitted when the notification opens.
* @event sd-after-show - Emitted after the notification opens and all animations are complete.
* @event sd-hide - Emitted when the notification closes.
* @event sd-after-hide - Emitted after the notification closes and all animations are complete.
*
* @csspart base - The component's base wrapper.
* @csspart icon - The container that wraps the optional icon.
* @csspart content - The container that wraps the notifications's main content and the close button.
* @csspart message - The container that wraps the notifications's main content.
* @csspart duration-indicator__elapsed - The current duration indicator.
* @csspart duration-indicator__total - The total duration indicator.
* @csspart close-button - The close button, an `<sd-icon-button>`.
*
* @animation notification.show - The animation to use when showing the sd-notification.
* @animation notifiation.hide - The animation to use when hiding the sd-notification.
*/
@customElement('sd-notification')
export default class SdNotification extends SolidElement {
private autoHideTimeout: number;
private readonly localize = new LocalizeController(this);
@query('[part~="base"]') base: HTMLElement;
/**
* Indicates whether or not sd-notification is open. You can toggle this attribute to show and hide the notification, or you can
* use the `show()` and `hide()` methods and this attribute will reflect the notifications's open state.
*/
@property({ type: Boolean, reflect: true }) open = false;
/** Enables a close button that allows the user to dismiss the notification. */
@property({ type: Boolean, reflect: true }) closable = false;
/** The sd-notification's theme. */
@property({ reflect: true }) variant: 'info' | 'success' | 'error' | 'warning' = 'info';
/** The position of the toasted sd-notification. */
@property({ reflect: true, attribute: 'toast-stack' }) toastStack: 'top-right' | 'bottom-center' = 'top-right';
/**
* The length of time, in milliseconds, the sd-notification will show before closing itself. If the user interacts with
* the notification before it closes (e.g. moves the mouse over it), the timer will restart. Defaults to `Infinity`, meaning
* the notification will not close on its own.
*/
@property({ type: Number }) duration = Infinity;
/** Enables an animation that visualizes the duration of a notification. */
@property({ type: Boolean, reflect: true, attribute: 'duration-indicator' }) durationIndicator = false;
private remainingDuration = this.duration;
private startTime = Date.now();
firstUpdated() {
this.base.hidden = !this.open;
}
private startAutoHide() {
clearTimeout(this.autoHideTimeout);
this.startTime = Date.now();
this.remainingDuration = this.duration;
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => this.hide(), this.duration);
}
}
private onHover() {
clearTimeout(this.autoHideTimeout);
if (this.duration < Infinity) {
this.remainingDuration -= Date.now() - this.startTime;
}
}
private onHoverEnd() {
this.startTime = Date.now();
clearTimeout(this.autoHideTimeout);
if (this.open && this.duration < Infinity) {
this.autoHideTimeout = window.setTimeout(() => {
this.hide();
}, this.remainingDuration);
}
}
private handleCloseClick() {
this.hide();
}
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
this.emit('sd-show');
if (this.duration < Infinity) {
this.startAutoHide();
}
await stopAnimations(this.base);
this.base.hidden = false;
const { keyframes, options } = getAnimation(this, 'notification.show', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.emit('sd-after-show');
} else {
// Hide
this.emit('sd-hide');
clearTimeout(this.autoHideTimeout);
await stopAnimations(this.base);
const { keyframes, options } = getAnimation(this, 'notification.hide', { dir: this.localize.dir() });
await animateTo(this.base, keyframes, options);
this.base.hidden = true;
this.emit('sd-after-hide');
}
}
@watch('duration')
handleDurationChange() {
this.startAutoHide();
}
/** Shows the notification. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'sd-after-show');
}
/** Hides the notification */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'sd-after-hide');
}
/**
* Displays the notification as a toast notification. This will move the notification out of its position in the DOM and, when
* dismissed, it will be removed from the DOM completely. By storing a reference to the notification, you can reuse it by
* calling this method again. The returned promise will resolve after the notification is hidden.
*/
async toast() {
return new Promise<void>(resolve => {
const toastStack = this.toastStack === 'bottom-center' ? toastStackBottomCenter : toastStackDefault;
if (toastStack.parentElement === null) {
document.body.append(toastStack);
}
toastStack.appendChild(this);
// Wait for the toast stack to render
requestAnimationFrame(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- force a reflow for the initial transition
this.clientWidth;
this.show();
});
this.addEventListener(
'sd-after-hide',
() => {
toastStack.removeChild(this);
resolve();
// Remove the toast stack from the DOM when there are no more alerts
if (toastStack.querySelector('sd-notification') === null) {
toastStack.remove();
}
},
{ once: true }
);
});
}
render() {
return html`
<div
part="base"
class=${cx('w-full overflow-hidden flex items-stretch relative m-2')}
role="alert"
id="notification"
aria-hidden=${this.open ? 'false' : 'true'}
@mouseenter=${this.onHover}
@mouseleave=${this.onHoverEnd}
>
<slot
name="icon"
part="icon"
class=${cx(
'min-w-min flex items-center px-3 justify-center',
{
info: 'bg-info',
success: 'bg-success',
warning: 'bg-warning',
error: 'bg-error'
}[this.variant]
)}
>
<sd-icon
name=${{
info: 'info-circle',
success: 'confirm-circle',
warning: 'exclamation-circle',
error: 'warning'
}[this.variant] || ''}
library="system"
class="h-6 w-6 text-white"
></sd-icon>
</slot>
<div
part="content"
class=${cx(
'h-full w-full p-1 gap-2 flex items-center justify-stretch bg-white',
'border-solid border-[1px] border-l-0 border-neutral-400'
)}
>
<slot part="message" class="block w-full pl-3 py-2" aria-live="polite"></slot>
${this.closable
? html`
<sd-button
size="md"
variant="tertiary"
part="close-button"
class="ml-auto flex flex-[0_0_auto] items-stretch"
label=${this.localize.term('close')}
@click=${this.handleCloseClick}
>
<sd-icon name="close" library="system" color="currentColor"></sd-icon>
</sd-button>
`
: ''}
</div>
${this.durationIndicator
? html`
<div
part="duration-indicator__elapsed"
id="duration-indicator__elapsed"
style=${`animation-duration: ${this.duration}ms`}
class=${cx(`absolute w-0 h-[2px] bottom-0 bg-primary z-10 animate-grow`)}
></div>
<div part="duration-indicator__total" class="w-full h-[2px] bottom-0 absolute bg-neutral-400"></div>
`
: ''}
</div>
`;
}
/**
* Inherits Tailwindclasses and includes additional styling.
*/
static styles = [
componentStyles,
SolidElement.styles,
css`
:host {
display: contents;
}
#notification:hover #duration-indicator__elapsed {
animation-play-state: paused !important;
}
`
];
}
setDefaultAnimation('notification.show', {
keyframes: [
{ opacity: 0, scale: 0.8 },
{ opacity: 1, scale: 1 }
],
options: { duration: 250, easing: 'ease' }
});
setDefaultAnimation('notification.hide', {
keyframes: [
{ opacity: 1, scale: 1 },
{ opacity: 0, scale: 0.8 }
],
options: { duration: 250, easing: 'ease' }
});
declare global {
interface HTMLElementTagNameMap {
'sd-notification': SdNotification;
}
}