@justeattakeaway/pie-toast-provider 1.2.3 → 1.3.0

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/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 _currentToast: ExtendedToastProps | null = null;
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
- * Handles the dismissal of the current toast and displays the next one in the queue (if any).
134
+ * Starts the slide-out animation for the dismissed toast.
79
135
  */
80
- private _dismissToast () {
81
- this._currentToast?.onPieToastClose?.();
82
- this._currentToast = null;
83
- requestAnimationFrame(() => { this._showNextToast(); });
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
- * Displays the next toast in the queue, if available.
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
- if (this._toasts.length > 0) {
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._currentToast = nextToast;
203
+ this._visibleToasts = [...this._visibleToasts, nextToast];
93
204
  this._toasts = remainingToasts;
94
- } else {
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 and triggers its display if no toast is currently active.
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
- if (!this._currentToast) {
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._currentToast = null;
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
- _currentToast,
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
- const toastClasses = {
139
- 'pie-animation--slide-in': Boolean(_currentToast),
140
- 'pie-animation--slide-out': _currentToast === null,
141
- };
142
-
143
- const isError = _currentToast?.variant === 'error';
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
- // The provider owns the announcement, so the rendered toast's own live region is disabled
146
- // (`aria-live: off`) to prevent the message being announced twice.
147
- const toastAria = { ..._currentToast?.aria, live: 'off' as const };
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 is-visuallyHidden"
155
- role="${isError ? 'alert' : 'status'}"
156
- aria-live="${isError ? 'assertive' : 'polite'}"
157
- aria-atomic="true"
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
- ${_currentToast?.message ?? ''}
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
- ${_currentToast &&
162
- html`
163
- <pie-toast
164
- class=${classMap(toastClasses)}
165
- message="${_currentToast.message}"
166
- variant="${ifDefined(_currentToast.variant)}"
167
- ?isStrong="${_currentToast.isStrong}"
168
- ?isDismissible="${_currentToast.isDismissible}"
169
- ?isMultiline="${_currentToast.isMultiline}"
170
- .leadingAction="${_currentToast.leadingAction}"
171
- .aria="${toastAria}"
172
- .duration="${typeof _currentToast.duration === 'undefined' ? nothing : _currentToast.duration}"
173
- @pie-toast-close="${this._dismissToast}"
174
- @pie-toast-open="${_currentToast.onPieToastOpen}"
175
- @pie-toast-leading-action-click="${_currentToast.onPieToastLeadingActionClick}">
176
- </pie-toast>
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
  }
@@ -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
+ }