@@ -17,7 +17,138 @@ export type ToastHandle = {
1717} ;
1818
1919let container : HTMLElement | null = null ;
20- const activeToasts = new Map < HTMLElement , number > ( ) ;
20+
21+ type ToastState = {
22+ timeoutId ?: number ;
23+ rafId ?: number ;
24+ startTime : number ;
25+ endTime : number ;
26+ remaining : number ;
27+ totalDuration : number ;
28+ progressTrack : HTMLElement | null ;
29+ progressBar : HTMLElement | null ;
30+ } ;
31+
32+ const toastStates = new Map < HTMLElement , ToastState > ( ) ;
33+
34+ function isFiniteDuration ( duration : number ) : duration is number {
35+ return Number . isFinite ( duration ) && duration > 0 ;
36+ }
37+
38+ function ensureProgressElements ( toastEl : HTMLElement ) : { track : HTMLElement ; bar : HTMLElement } {
39+ let track = toastEl . querySelector < HTMLElement > ( ".qs-toast-progress" ) ?? null ;
40+ let bar = track ?. querySelector < HTMLElement > ( ".qs-toast-progress__bar" ) ?? null ;
41+
42+ if ( ! track ) {
43+ track = document . createElement ( "div" ) ;
44+ track . className = "qs-toast-progress qs-toast-progress--hidden" ;
45+ track . setAttribute ( "aria-hidden" , "true" ) ;
46+ } else if ( ! track . classList . contains ( "qs-toast-progress--hidden" ) && ! track . classList . contains ( "qs-toast-progress--visible" ) ) {
47+ track . classList . add ( "qs-toast-progress--hidden" ) ;
48+ }
49+
50+ if ( ! bar ) {
51+ bar = document . createElement ( "div" ) ;
52+ bar . className = "qs-toast-progress__bar" ;
53+ bar . style . transform = "scaleX(0)" ;
54+ track . appendChild ( bar ) ;
55+ } else if ( bar . parentElement !== track ) {
56+ track . appendChild ( bar ) ;
57+ }
58+
59+ if ( track . parentElement !== toastEl ) {
60+ toastEl . appendChild ( track ) ;
61+ } else if ( track . nextSibling ) {
62+ toastEl . appendChild ( track ) ;
63+ }
64+
65+ return { track, bar } ;
66+ }
67+
68+ function getOrCreateState ( toastEl : HTMLElement ) : ToastState {
69+ const { track, bar } = ensureProgressElements ( toastEl ) ;
70+ let state = toastStates . get ( toastEl ) ;
71+ if ( ! state ) {
72+ const now = performance . now ( ) ;
73+ state = {
74+ timeoutId : undefined ,
75+ rafId : undefined ,
76+ startTime : now ,
77+ endTime : now ,
78+ remaining : Infinity ,
79+ totalDuration : Infinity ,
80+ progressTrack : track ,
81+ progressBar : bar ,
82+ } ;
83+ toastStates . set ( toastEl , state ) ;
84+ } else {
85+ state . progressTrack = track ;
86+ state . progressBar = bar ;
87+ }
88+ return state ;
89+ }
90+
91+ function updateProgressBar ( toastEl : HTMLElement , value : number | null ) : void {
92+ let state = toastStates . get ( toastEl ) ;
93+ if ( ! state || ! state . progressTrack || ! state . progressBar ) {
94+ state = getOrCreateState ( toastEl ) ;
95+ }
96+ if ( ! state . progressTrack || ! state . progressBar ) return ;
97+
98+ if ( value === null || Number . isNaN ( value ) || value < 0 ) {
99+ state . progressTrack . classList . remove ( "qs-toast-progress--visible" ) ;
100+ state . progressTrack . classList . add ( "qs-toast-progress--hidden" ) ;
101+ state . progressBar . style . transform = "scaleX(0)" ;
102+ return ;
103+ }
104+
105+ const clamped = Math . min ( 1 , Math . max ( 0 , value ) ) ;
106+ state . progressTrack . classList . remove ( "qs-toast-progress--hidden" ) ;
107+ state . progressTrack . classList . add ( "qs-toast-progress--visible" ) ;
108+ state . progressBar . style . transform = `scaleX(${ clamped } )` ;
109+ }
110+
111+ function tickProgress ( toastEl : HTMLElement ) : void {
112+ const state = toastStates . get ( toastEl ) ;
113+ if ( ! state ) return ;
114+
115+ if ( ! isFiniteDuration ( state . totalDuration ) ) {
116+ updateProgressBar ( toastEl , null ) ;
117+ state . rafId = undefined ;
118+ return ;
119+ }
120+
121+ const now = performance . now ( ) ;
122+ const remaining = Math . max ( 0 , state . endTime - now ) ;
123+ state . remaining = remaining ;
124+ const progress = state . totalDuration <= 0 ? 1 : 1 - remaining / state . totalDuration ;
125+ updateProgressBar ( toastEl , progress ) ;
126+
127+ if ( remaining > 0 ) {
128+ state . rafId = window . requestAnimationFrame ( ( ) => tickProgress ( toastEl ) ) ;
129+ } else {
130+ state . rafId = undefined ;
131+ }
132+ }
133+
134+ function resumeToastCountdown ( toastEl : HTMLElement ) : void {
135+ const state = toastStates . get ( toastEl ) ;
136+ if ( state && isFiniteDuration ( state . remaining ) ) {
137+ if ( state . remaining <= 0 ) {
138+ dismissToast ( toastEl ) ;
139+ return ;
140+ }
141+ scheduleRemoval ( toastEl , state . remaining , false ) ;
142+ return ;
143+ }
144+
145+ const fallback = getToastDurationFromMetadata ( toastEl ) ;
146+ if ( fallback && isFiniteDuration ( fallback ) ) {
147+ scheduleRemoval ( toastEl , fallback ) ;
148+ } else {
149+ updateProgressBar ( toastEl , null ) ;
150+ }
151+ }
21152
22153function resolveToastBody ( message : string | HTMLElement ) : string | HTMLElement {
23154 if ( typeof message !== "string" ) {
@@ -69,23 +200,40 @@ function getToastDurationFromMetadata(toastEl: HTMLElement): number | null {
69200 return Number . isFinite ( parsed ) && parsed > 0 ? parsed : null ;
70201}
71202
72- function scheduleRemoval ( toastEl : HTMLElement , duration : number ) : void {
203+ function scheduleRemoval ( toastEl : HTMLElement , duration : number , resetTotal = true ) : void {
204+ const state = getOrCreateState ( toastEl ) ;
73205 clearRemoval ( toastEl ) ;
74206 if ( duration <= 0 || ! isFinite ( duration ) ) {
207+ state . remaining = Infinity ;
208+ state . endTime = Infinity ;
209+ updateProgressBar ( toastEl , null ) ;
75210 return ;
76211 }
77- const timeoutId = window . setTimeout ( ( ) => {
212+ const start = performance . now ( ) ;
213+ state . startTime = start ;
214+ state . endTime = start + duration ;
215+ state . remaining = duration ;
216+ if ( resetTotal ) {
217+ state . totalDuration = duration ;
218+ }
219+ updateProgressBar ( toastEl , state . totalDuration <= 0 ? 1 : 1 - state . remaining / state . totalDuration ) ;
220+ state . timeoutId = window . setTimeout ( ( ) => {
78221 dismissToast ( toastEl ) ;
79222 } , duration ) ;
80- activeToasts . set ( toastEl , timeoutId ) ;
223+ state . rafId = window . requestAnimationFrame ( ( ) => tickProgress ( toastEl ) ) ;
81224}
82225
83226function clearRemoval ( toastEl : HTMLElement ) : void {
84- const timeoutId = activeToasts . get ( toastEl ) ;
85- if ( typeof timeoutId === "number" ) {
86- clearTimeout ( timeoutId ) ;
227+ const state = toastStates . get ( toastEl ) ;
228+ if ( ! state ) return ;
229+ if ( typeof state . timeoutId === "number" ) {
230+ clearTimeout ( state . timeoutId ) ;
231+ state . timeoutId = undefined ;
232+ }
233+ if ( typeof state . rafId === "number" ) {
234+ cancelAnimationFrame ( state . rafId ) ;
235+ state . rafId = undefined ;
87236 }
88- activeToasts . delete ( toastEl ) ;
89237}
90238
91239function cleanupContainer ( ) : void {
@@ -98,6 +246,7 @@ function cleanupContainer(): void {
98246
99247function dismissToast ( toastEl : HTMLElement , reason : "default" | "swipe" = "default" ) : void {
100248 clearRemoval ( toastEl ) ;
249+ toastStates . delete ( toastEl ) ;
101250 toastEl . classList . remove ( "qs-toast--enter" , "qs-toast--swipe-exit" ) ;
102251 toastEl . classList . add ( reason === "swipe" ? "qs-toast--swipe-exit" : "qs-toast--exit" ) ;
103252
@@ -191,12 +340,14 @@ export function showToast(message: string | HTMLElement, options: ToastOptions =
191340
192341 toastEl . addEventListener ( "mouseenter" , ( ) => {
193342 clearRemoval ( toastEl ) ;
343+ const state = toastStates . get ( toastEl ) ;
344+ if ( state && Number . isFinite ( state . remaining ) ) {
345+ const now = performance . now ( ) ;
346+ state . remaining = Math . max ( 0 , state . endTime - now ) ;
347+ }
194348 } ) ;
195349 toastEl . addEventListener ( "mouseleave" , ( ) => {
196- const stored = getToastDurationFromMetadata ( toastEl ) ;
197- if ( stored ) {
198- scheduleRemoval ( toastEl , stored ) ;
199- }
350+ resumeToastCountdown ( toastEl ) ;
200351 } ) ;
201352
202353 // Gesture support for swipe dismissal (mouse + touch)
@@ -235,10 +386,7 @@ export function showToast(message: string | HTMLElement, options: ToastOptions =
235386 dismissToast ( toastEl , "swipe" ) ;
236387 } else {
237388 toastEl . classList . add ( "qs-toast--enter" ) ;
238- const stored = getToastDurationFromMetadata ( toastEl ) ;
239- if ( stored ) {
240- scheduleRemoval ( toastEl , stored ) ;
241- }
389+ resumeToastCountdown ( toastEl ) ;
242390 }
243391 } ;
244392
0 commit comments