@justeattakeaway/pie-toast-provider 1.2.3 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/custom-elements.json +204 -8
- package/dist/index.d.ts +63 -6
- package/dist/index.js +201 -117
- package/dist/react.d.ts +63 -6
- package/dist/react.js +4 -3
- package/package.json +5 -5
- package/src/defs.ts +16 -0
- package/src/index.ts +180 -51
- package/src/toast-provider.scss +27 -1
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { PieElement } from '@justeattakeaway/pie-webc-core/src/internals/PieElem
|
|
|
8
8
|
import { state, property } from 'lit/decorators.js';
|
|
9
9
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
10
10
|
import { classMap } from 'lit/directives/class-map.js';
|
|
11
|
+
import { live } from 'lit/directives/live.js';
|
|
12
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
11
13
|
import {
|
|
12
14
|
dispatchCustomEvent,
|
|
13
15
|
safeCustomElement,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
positions,
|
|
20
22
|
defaultProps,
|
|
21
23
|
PRIORITY_ORDER,
|
|
24
|
+
MAX_VISIBLE_TOASTS,
|
|
22
25
|
type Priority,
|
|
23
26
|
type ToastProviderProps,
|
|
24
27
|
type ExtendedToastProps,
|
|
@@ -31,6 +34,9 @@ export { toaster } from './toaster';
|
|
|
31
34
|
|
|
32
35
|
const componentSelector = 'pie-toast-provider';
|
|
33
36
|
|
|
37
|
+
// Fallback timeout if motion events don't fire (e.g., when motion is disabled).
|
|
38
|
+
const MOTION_FALLBACK_MS = 500;
|
|
39
|
+
|
|
34
40
|
/**
|
|
35
41
|
* @tagname pie-toast-provider
|
|
36
42
|
* @event {CustomEvent} pie-toast-provider-queue-update - when a toast is added or removed from the queue.
|
|
@@ -44,11 +50,61 @@ export class PieToastProvider extends PieElement implements ToastProviderProps {
|
|
|
44
50
|
@validPropertyValues(componentSelector, positions, defaultProps.position)
|
|
45
51
|
public position = defaultProps.position;
|
|
46
52
|
|
|
53
|
+
@property({ type: Boolean })
|
|
54
|
+
public isStacked = defaultProps.isStacked;
|
|
55
|
+
|
|
47
56
|
@state()
|
|
48
57
|
private _toasts: ExtendedToastProps[] = [];
|
|
49
58
|
|
|
50
59
|
@state()
|
|
51
|
-
private
|
|
60
|
+
private _visibleToasts: ExtendedToastProps[] = [];
|
|
61
|
+
|
|
62
|
+
@state()
|
|
63
|
+
private _dismissingToasts: ExtendedToastProps[] = [];
|
|
64
|
+
|
|
65
|
+
@state()
|
|
66
|
+
private _collapsingToasts: ExtendedToastProps[] = [];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Messages currently held in the live regions, in the order they became visible.
|
|
70
|
+
*
|
|
71
|
+
* The toast objects themselves are reused as entries: they are unique per `createToast`, which
|
|
72
|
+
* gives `repeat` a stable key and lets the entries be pruned by identity when a toast leaves.
|
|
73
|
+
*/
|
|
74
|
+
@state()
|
|
75
|
+
private _announcements: ExtendedToastProps[] = [];
|
|
76
|
+
|
|
77
|
+
/** Pending {@link MOTION_FALLBACK_MS} timers, keyed by the toast they will advance. */
|
|
78
|
+
private _motionFallbacks = new Map<ExtendedToastProps, ReturnType<typeof setTimeout>>();
|
|
79
|
+
|
|
80
|
+
public disconnectedCallback (): void {
|
|
81
|
+
this._clearMotionFallbacks();
|
|
82
|
+
super.disconnectedCallback();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Replaces any pending fallback for `toast` with one that runs `advance`.
|
|
87
|
+
*
|
|
88
|
+
* Each phase supersedes the previous phase's fallback, so a toast only ever has one timer.
|
|
89
|
+
*/
|
|
90
|
+
private _scheduleMotionFallback (toast: ExtendedToastProps, advance: () => void): void {
|
|
91
|
+
this._clearMotionFallback(toast);
|
|
92
|
+
this._motionFallbacks.set(toast, setTimeout(advance, MOTION_FALLBACK_MS));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _clearMotionFallback (toast: ExtendedToastProps): void {
|
|
96
|
+
const timer = this._motionFallbacks.get(toast);
|
|
97
|
+
|
|
98
|
+
if (timer) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
this._motionFallbacks.delete(toast);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private _clearMotionFallbacks (): void {
|
|
105
|
+
this._motionFallbacks.forEach((timer) => clearTimeout(timer));
|
|
106
|
+
this._motionFallbacks.clear();
|
|
107
|
+
}
|
|
52
108
|
|
|
53
109
|
updated (changedProperties: PropertyValues<this>): void {
|
|
54
110
|
if (changedProperties.has('_toasts' as keyof PieToastProvider)) {
|
|
@@ -75,59 +131,114 @@ export class PieToastProvider extends PieElement implements ToastProviderProps {
|
|
|
75
131
|
}
|
|
76
132
|
|
|
77
133
|
/**
|
|
78
|
-
*
|
|
134
|
+
* Starts the slide-out animation for the dismissed toast.
|
|
79
135
|
*/
|
|
80
|
-
private _dismissToast () {
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
|
|
136
|
+
private _dismissToast (toast: ExtendedToastProps) {
|
|
137
|
+
if (this._dismissingToasts.includes(toast)) return;
|
|
138
|
+
toast.onPieToastClose?.();
|
|
139
|
+
this._dismissingToasts = [...this._dismissingToasts, toast];
|
|
140
|
+
this._scheduleMotionFallback(toast, () => this._finalizeDismiss(toast));
|
|
84
141
|
}
|
|
85
142
|
|
|
86
143
|
/**
|
|
87
|
-
*
|
|
144
|
+
* Forwards the toast's open event to the consumer.
|
|
145
|
+
*
|
|
146
|
+
* `isOpen` is bound with the `live` directive, so a toast that sets `isOpen` to false while
|
|
147
|
+
* dismissing is reopened on the next render in order to keep it visible for the slide-out
|
|
148
|
+
* animation. That reopen dispatches a second `pie-toast-open`, which is not a real open, so it
|
|
149
|
+
* is not forwarded.
|
|
150
|
+
*/
|
|
151
|
+
private _handleToastOpen (toast: ExtendedToastProps) {
|
|
152
|
+
if (this._dismissingToasts.includes(toast)) return;
|
|
153
|
+
toast.onPieToastOpen?.();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private _getToastClass (toast: ExtendedToastProps): string {
|
|
157
|
+
if (this._dismissingToasts.includes(toast)) return 'pie-animation--slide-out';
|
|
158
|
+
if (this._collapsingToasts.includes(toast)) return '';
|
|
159
|
+
return 'pie-animation--slide-in';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private _getToastDuration (toast: ExtendedToastProps) {
|
|
163
|
+
if (this._dismissingToasts.includes(toast) || this._collapsingToasts.includes(toast)) return null;
|
|
164
|
+
return typeof toast.duration === 'undefined' ? nothing : toast.duration;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Starts the height-collapse phase after the slide-out animation ends.
|
|
169
|
+
*/
|
|
170
|
+
private _finalizeDismiss (toast: ExtendedToastProps) {
|
|
171
|
+
if (!this._dismissingToasts.includes(toast)) return;
|
|
172
|
+
|
|
173
|
+
this._dismissingToasts = this._dismissingToasts.filter((t) => t !== toast);
|
|
174
|
+
this._collapsingToasts = [...this._collapsingToasts, toast];
|
|
175
|
+
this._scheduleMotionFallback(toast, () => this._finalizeCollapse(toast));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Called after the collapse transition ends. Removes the toast from DOM and promotes the next queued toast.
|
|
180
|
+
*/
|
|
181
|
+
private _finalizeCollapse (toast: ExtendedToastProps) {
|
|
182
|
+
if (!this._collapsingToasts.includes(toast)) return;
|
|
183
|
+
|
|
184
|
+
this._clearMotionFallback(toast);
|
|
185
|
+
this._collapsingToasts = this._collapsingToasts.filter((t) => t !== toast);
|
|
186
|
+
this._visibleToasts = this._visibleToasts.filter((t) => t !== toast);
|
|
187
|
+
this._announcements = this._announcements.filter((t) => t !== toast);
|
|
188
|
+
this._showNextToast();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fills the visible slots from the queue.
|
|
193
|
+
*
|
|
194
|
+
* The loop only ever adds toasts, so turning `isStacked` off while several are on screen
|
|
195
|
+
* does not remove any: the extras stay until they dismiss normally, then the provider
|
|
196
|
+
* settles back to one at a time.
|
|
88
197
|
*/
|
|
89
198
|
private _showNextToast () {
|
|
90
|
-
|
|
199
|
+
const maxVisibleToasts = this.isStacked ? MAX_VISIBLE_TOASTS : 1;
|
|
200
|
+
|
|
201
|
+
while (this._visibleToasts.length < maxVisibleToasts && this._toasts.length > 0) {
|
|
91
202
|
const [nextToast, ...remainingToasts] = this._toasts;
|
|
92
|
-
this.
|
|
203
|
+
this._visibleToasts = [...this._visibleToasts, nextToast];
|
|
93
204
|
this._toasts = remainingToasts;
|
|
94
|
-
|
|
95
|
-
this._currentToast = null;
|
|
205
|
+
this._announcements = [...this._announcements, nextToast];
|
|
96
206
|
}
|
|
97
207
|
}
|
|
98
208
|
|
|
99
209
|
/**
|
|
100
|
-
* Adds a new toast to the queue
|
|
210
|
+
* Adds a new toast to the queue (sorted by priority) and fills visible slots.
|
|
101
211
|
* @param {ToastProps} toast - The toast props to display.
|
|
102
212
|
*/
|
|
103
213
|
public createToast (toast: ExtendedToastProps) {
|
|
104
214
|
const newToast = { ...toastDefaultProps, ...this.options, ...toast };
|
|
105
215
|
|
|
106
216
|
this._toasts = [...this._toasts, newToast].sort((a, b) => {
|
|
107
|
-
const priorityB = this.getPriority(b.variant, !!b.leadingAction?.text);
|
|
108
217
|
const priorityA = this.getPriority(a.variant, !!a.leadingAction?.text);
|
|
218
|
+
const priorityB = this.getPriority(b.variant, !!b.leadingAction?.text);
|
|
109
219
|
|
|
110
220
|
return priorityA - priorityB;
|
|
111
221
|
});
|
|
112
222
|
|
|
113
|
-
|
|
114
|
-
this._showNextToast();
|
|
115
|
-
}
|
|
223
|
+
this._showNextToast();
|
|
116
224
|
}
|
|
117
225
|
|
|
118
226
|
/**
|
|
119
|
-
*
|
|
120
|
-
* Clears all toasts from the queue and dismisses the currently visible toast.
|
|
227
|
+
* Clears all toasts from the queue and dismisses all visible toasts.
|
|
121
228
|
*/
|
|
122
229
|
public clearToasts () {
|
|
230
|
+
this._clearMotionFallbacks();
|
|
123
231
|
this._toasts = [];
|
|
124
|
-
this.
|
|
232
|
+
this._visibleToasts = [];
|
|
233
|
+
this._dismissingToasts = [];
|
|
234
|
+
this._collapsingToasts = [];
|
|
235
|
+
this._announcements = [];
|
|
125
236
|
}
|
|
126
237
|
|
|
127
238
|
render () {
|
|
128
239
|
const {
|
|
129
240
|
position,
|
|
130
|
-
|
|
241
|
+
_visibleToasts,
|
|
131
242
|
} = this;
|
|
132
243
|
|
|
133
244
|
const classes = {
|
|
@@ -135,46 +246,64 @@ export class PieToastProvider extends PieElement implements ToastProviderProps {
|
|
|
135
246
|
[`c-toast-provider--${position}`]: true,
|
|
136
247
|
};
|
|
137
248
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
249
|
+
// Two regions with fixed politeness, rather than one region that switches: a neutral toast
|
|
250
|
+
// arriving after an error must not downgrade the error's announcement.
|
|
251
|
+
//
|
|
252
|
+
// `aria-atomic="false"` is what makes batching work. Lit renders several newly visible
|
|
253
|
+
// toasts in a single update, and assistive tech announces only the nodes added in that
|
|
254
|
+
// update, so each message is read in turn instead of the last one replacing the rest.
|
|
255
|
+
// `aria-relevant` defaults to `additions text`, so pruning a departed toast is silent.
|
|
256
|
+
const politeAnnouncements = this._announcements.filter(({ variant }) => variant !== 'error');
|
|
257
|
+
const assertiveAnnouncements = this._announcements.filter(({ variant }) => variant === 'error');
|
|
144
258
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
259
|
+
const renderAnnouncements = (announcements: ExtendedToastProps[]) => repeat(
|
|
260
|
+
announcements,
|
|
261
|
+
(toast) => toast,
|
|
262
|
+
(toast) => html`<div>${toast.message}</div>`,
|
|
263
|
+
);
|
|
148
264
|
|
|
149
265
|
return html`
|
|
150
266
|
<div
|
|
151
267
|
class=${classMap(classes)}
|
|
152
268
|
data-test-id="pie-toast-provider">
|
|
153
269
|
<div
|
|
154
|
-
class="c-toast-provider-announcer
|
|
155
|
-
role="
|
|
156
|
-
aria-live="
|
|
157
|
-
aria-atomic="
|
|
270
|
+
class="c-toast-provider-announcer"
|
|
271
|
+
role="status"
|
|
272
|
+
aria-live="polite"
|
|
273
|
+
aria-atomic="false"
|
|
158
274
|
data-test-id="pie-toast-provider-announcer">
|
|
159
|
-
${
|
|
275
|
+
${renderAnnouncements(politeAnnouncements)}
|
|
276
|
+
</div>
|
|
277
|
+
<div
|
|
278
|
+
class="c-toast-provider-announcer"
|
|
279
|
+
role="alert"
|
|
280
|
+
aria-live="assertive"
|
|
281
|
+
aria-atomic="false"
|
|
282
|
+
data-test-id="pie-toast-provider-announcer-assertive">
|
|
283
|
+
${renderAnnouncements(assertiveAnnouncements)}
|
|
160
284
|
</div>
|
|
161
|
-
${
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
285
|
+
${repeat(_visibleToasts, (toast) => toast, (toast) => html`
|
|
286
|
+
<div
|
|
287
|
+
class="c-toast-provider-item${this._collapsingToasts.includes(toast) ? ' c-toast-provider-item--collapsing' : ''}"
|
|
288
|
+
@transitionend="${() => { if (this._collapsingToasts.includes(toast)) this._finalizeCollapse(toast); }}">
|
|
289
|
+
<pie-toast
|
|
290
|
+
class="${this._getToastClass(toast)}"
|
|
291
|
+
.isOpen="${live(true)}"
|
|
292
|
+
message="${toast.message}"
|
|
293
|
+
variant="${ifDefined(toast.variant)}"
|
|
294
|
+
?isStrong="${toast.isStrong}"
|
|
295
|
+
?isDismissible="${toast.isDismissible}"
|
|
296
|
+
?isMultiline="${toast.isMultiline}"
|
|
297
|
+
.leadingAction="${toast.leadingAction}"
|
|
298
|
+
.aria="${{ ...toast.aria, live: 'off' as const }}"
|
|
299
|
+
.duration="${this._getToastDuration(toast)}"
|
|
300
|
+
@pie-toast-close="${() => this._dismissToast(toast)}"
|
|
301
|
+
@pie-toast-open="${() => this._handleToastOpen(toast)}"
|
|
302
|
+
@pie-toast-leading-action-click="${toast.onPieToastLeadingActionClick}"
|
|
303
|
+
@animationend="${() => { if (this._dismissingToasts.includes(toast)) this._finalizeDismiss(toast); }}">
|
|
304
|
+
</pie-toast>
|
|
305
|
+
</div>
|
|
306
|
+
`)}
|
|
178
307
|
</div>
|
|
179
308
|
`;
|
|
180
309
|
}
|
package/src/toast-provider.scss
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
.c-toast-provider {
|
|
16
16
|
position: var(--toast-provider-position);
|
|
17
17
|
z-index: var(--toast-provider-z-index);
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column-reverse;
|
|
18
20
|
|
|
19
21
|
&-announcer {
|
|
20
22
|
@include p.visually-hidden;
|
|
@@ -43,6 +45,30 @@
|
|
|
43
45
|
bottom: var(--toast-provider-offset);
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
.c-toast-provider-item {
|
|
49
|
+
display: grid;
|
|
50
|
+
grid-template-rows: 1fr;
|
|
51
|
+
|
|
52
|
+
padding-block-start: var(--dt-spacing-d);
|
|
53
|
+
transition:
|
|
54
|
+
grid-template-rows var(--dt-motion-timing-200) var(--dt-motion-easing-in),
|
|
55
|
+
padding-block-start var(--dt-motion-timing-200) var(--dt-motion-easing-in);
|
|
56
|
+
|
|
57
|
+
> pie-toast {
|
|
58
|
+
min-block-size: 0;
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&--collapsing {
|
|
63
|
+
grid-template-rows: 0fr;
|
|
64
|
+
padding-block-start: 0;
|
|
65
|
+
|
|
66
|
+
> pie-toast {
|
|
67
|
+
visibility: hidden;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
&.c-toast-provider--bottom-center {
|
|
47
73
|
--pie-animation-slide-translate-start: 0, 100%;
|
|
48
74
|
|
|
@@ -50,4 +76,4 @@
|
|
|
50
76
|
bottom: var(--toast-provider-offset);
|
|
51
77
|
transform: translateX(-50%);
|
|
52
78
|
}
|
|
53
|
-
}
|
|
79
|
+
}
|