Skip to content

Commit bafbe32

Browse files
committed
feat: progress bar on toasts
1 parent 37a2117 commit bafbe32

File tree

4 files changed

+219
-17
lines changed

4 files changed

+219
-17
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
This documents the user-facing changes for each version
44

5+
## 0.4.1
6+
7+
- Show a progress bar on toasts
8+
59
## 0.4
610

711
- Fix notifications by using custom toasts

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "queue-manager",
3-
"version": "0.4",
3+
"version": "0.4.1",
44
"private": true,
55
"scripts": {
66
"build": "spicetify-creator",

src/toast.css

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
transform: translateY(-4px);
2828
opacity: 0;
2929
transition: transform 0.2s ease, opacity 0.2s ease;
30+
position: relative;
31+
overflow: hidden;
3032
}
3133

3234
.qs-toast--enter {
@@ -86,6 +88,54 @@
8688
display: none;
8789
}
8890

91+
.qs-toast-progress {
92+
position: absolute;
93+
left: 10px;
94+
right: 10px;
95+
bottom: 8px;
96+
height: 3px;
97+
border-radius: 999px;
98+
background: rgba(255, 255, 255, 0.15);
99+
transform-origin: left;
100+
transition: opacity 0.16s ease;
101+
opacity: 0;
102+
pointer-events: none;
103+
}
104+
105+
.qs-toast-progress__bar {
106+
width: 100%;
107+
height: 100%;
108+
border-radius: inherit;
109+
background: rgba(255, 255, 255, 0.7);
110+
transform-origin: left;
111+
transform: scaleX(0);
112+
transition: transform 0.08s linear;
113+
}
114+
115+
.qs-toast-progress--hidden {
116+
opacity: 0;
117+
}
118+
119+
.qs-toast-progress--visible {
120+
opacity: 1;
121+
}
122+
123+
.qs-toast--success .qs-toast-progress__bar {
124+
background: rgba(187, 247, 208, 0.85);
125+
}
126+
127+
.qs-toast--danger .qs-toast-progress__bar {
128+
background: rgba(254, 202, 202, 0.9);
129+
}
130+
131+
.qs-toast--warning .qs-toast-progress__bar {
132+
background: rgba(30, 41, 59, 0.9);
133+
}
134+
135+
.qs-toast--warning .qs-toast-progress {
136+
background: rgba(15, 23, 42, 0.35);
137+
}
138+
89139
.qs-toast--success {
90140
background: linear-gradient(135deg, rgba(34, 197, 94, 0.85), rgba(16, 185, 129, 0.85));
91141
border-color: rgba(34, 197, 94, 0.45);

src/toast.ts

Lines changed: 164 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,138 @@ export type ToastHandle = {
1717
};
1818

1919
let 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

22153
function 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

83226
function 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

91239
function cleanupContainer(): void {
@@ -98,6 +246,7 @@ function cleanupContainer(): void {
98246

99247
function 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

Comments
 (0)