Skip to content

Commit 67a2cda

Browse files
committed
Closes #23 - added sonner
1 parent 016f23a commit 67a2cda

File tree

5 files changed

+460
-9
lines changed

5 files changed

+460
-9
lines changed

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codewithkyle/notifyjs",
3-
"version": "4.1.0",
3+
"version": "5.0.0-alpha",
44
"description": "A simple JavaScript library for creating toast, snackbars, and notifications",
55
"type": "module",
66
"files": [
@@ -10,11 +10,12 @@
1010
"exports": {
1111
"./snackbar.js": "./dist/snackbar.js",
1212
"./notifications.js": "./dist/notifications.js",
13-
"./toaster.js": "./dist/toaster.js"
13+
"./toaster.js": "./dist/toaster.js",
14+
"./sonner.js": "./dist/sonner.js"
1415
},
1516
"scripts": {
1617
"cleanup": "node ./build/cleanup.js",
17-
"build": "tsc && esbuild ./src/snackbar.ts ./src/notifications.ts ./src/toaster.ts --format=esm --minify --bundle --outdir=dist",
18+
"build": "tsc && esbuild ./src/snackbar.ts ./src/notifications.ts ./src/toaster.ts ./src/sonner.ts --format=esm --minify --bundle --outdir=dist",
1819
"prerelease": "npm run cleanup && npm run build && cp ./src/types.d.ts ./dist/types.d.ts",
1920
"test": "npm run build && cp -r ./dist ./test/ && serve ./test",
2021
"deploy": "npm run build && cp -r ./dist ./test/ && node ./build/deploy.js"
@@ -24,6 +25,7 @@
2425
"notification",
2526
"toaster",
2627
"toast",
28+
"sonner",
2729
"web-components",
2830
"lightweight"
2931
],

src/sonner.ts

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import type { SonnerNotification } from "./types";
2+
3+
class Sonner extends HTMLElement {
4+
private previous: number|undefined;
5+
private queue: Array<SonnerNotification>;
6+
private doUpdate: boolean;
7+
private skipNextUpdate: boolean;
8+
private timeoutID: number|undefined;
9+
10+
constructor() {
11+
super();
12+
this.previous = undefined;
13+
this.queue = Array(3).fill(null);
14+
this.doUpdate = true;
15+
this.skipNextUpdate = false;
16+
this.timeoutID = undefined;
17+
}
18+
19+
connectedCallback() {
20+
document.addEventListener("visibilitychange", () => {
21+
this.doUpdate = !document.hidden;
22+
if (!this.doUpdate) this.skipNextUpdate = true;
23+
});
24+
this.addEventListener("mouseenter", () => {
25+
this.doUpdate = false;
26+
this.skipNextUpdate = true;
27+
this.classList.add("expand");
28+
this.expand();
29+
if (this.timeoutID !== undefined) {
30+
clearTimeout(this.timeoutID);
31+
}
32+
});
33+
this.addEventListener("mouseleave", () => {
34+
this.timeoutID = setTimeout(()=>{
35+
this.collapse();
36+
this.doUpdate = true;
37+
this.classList.remove("expand");
38+
this.timeoutID = undefined;
39+
}, 80);
40+
});
41+
window.addEventListener("notify:sonner", (e:CustomEvent) => {
42+
if (e?.detail) this.push(e.detail);
43+
});
44+
window.requestAnimationFrame(this.first.bind(this));
45+
}
46+
47+
private collapse() {
48+
for (let i = 0; i < 3; i++) {
49+
if (this.queue[i] === null) continue;
50+
this.queue[i].el.collapse();
51+
}
52+
this.style.height = "0px";
53+
}
54+
55+
private expand() {
56+
let bottomOffset = 0;
57+
for (let i = 0; i < 3; i++) {
58+
if (this.queue[i] === null) continue;
59+
this.queue[i].el.expand(bottomOffset);
60+
// @ts-ignore
61+
bottomOffset += this.queue[i].el.height + 8;
62+
}
63+
this.style.height = `${bottomOffset}px`;
64+
}
65+
66+
private loop(ts:number) {
67+
const dt = (ts - this.previous) * 0.001;
68+
this.previous = ts;
69+
70+
if (this.doUpdate) {
71+
if (!this.skipNextUpdate) {
72+
for (let i = 0; i < 3; i++) {
73+
if (this.queue[i] !== null) {
74+
if (this.queue[i].el.isConnected) {
75+
this.queue[i]?.el?.update(dt);
76+
} else {
77+
this.queue[i] = null;
78+
}
79+
}
80+
}
81+
this.reconcile();
82+
} else {
83+
this.skipNextUpdate = false;
84+
}
85+
}
86+
87+
window.requestAnimationFrame(this.loop.bind(this));
88+
}
89+
90+
private reconcile() {
91+
for (let i = 2; i >= 0; i--) {
92+
if (this.queue[i] === null) continue;
93+
else if (i === 0) break;
94+
95+
if (this.queue[i-1] === null) {
96+
this.queue[i-1] = this.queue[i];
97+
this.queue[i] = null;
98+
}
99+
}
100+
for (let i = 0; i < 3; i++) {
101+
if (this.queue[i] !== null) {
102+
this.queue[i].el.updateIndex(i);
103+
}
104+
}
105+
}
106+
107+
private first(ts:number) {
108+
this.previous = ts;
109+
window.requestAnimationFrame(this.loop.bind(this));
110+
}
111+
112+
public push(settings:Partial<SonnerNotification>) {
113+
const toast:SonnerNotification = Object.assign({
114+
heading: "",
115+
message: "",
116+
el: null,
117+
duration: 5,
118+
classes: [],
119+
button: {
120+
callback: ()=>{},
121+
label: "",
122+
classes: "",
123+
},
124+
}, settings);
125+
126+
if (toast.duration === Infinity || typeof toast.duration !== "number") {
127+
console.warn("Sonner duration must be a number. Defaulting to 5 seconds.");
128+
toast.duration = 5;
129+
}
130+
if (!Array.isArray(toast.classes)) {
131+
toast.classes = [toast.classes];
132+
}
133+
if (typeof toast.button?.callback !== "function") {
134+
console.warn("Sonner callback must be a function");
135+
toast.button.callback = ()=>{};
136+
}
137+
if (!toast.button?.classes) {
138+
toast.button.classes = [];
139+
}
140+
if (!Array.isArray(toast.button.classes)) {
141+
toast.button.classes = [toast.button.classes];
142+
}
143+
toast.el = new SonnerToast(toast);
144+
this.insert(toast);
145+
}
146+
147+
private insert(toast:SonnerNotification) {
148+
if (this.queue[2] !== null) {
149+
this.queue[2]?.el?.delete();
150+
}
151+
if (this.queue[1] !== null) {
152+
this.queue[2] = this.queue[1];
153+
}
154+
if (this.queue[0] !== null) {
155+
this.queue[1] = this.queue[0];
156+
}
157+
this.queue[0] = toast;
158+
this.appendChild(this.queue[0].el);
159+
}
160+
}
161+
if (!customElements.get("sonner-component")) {
162+
customElements.define("sonner-component", Sonner);
163+
}
164+
const sonner = new Sonner();
165+
document.body.appendChild(sonner);
166+
export default sonner;
167+
168+
class SonnerToast extends HTMLElement {
169+
private settings: SonnerNotification;
170+
private life: number;
171+
private height: number;
172+
private y: number;
173+
private offset: number;
174+
private index: number;
175+
private dead: boolean;
176+
private scale: number;
177+
private isExpanded: boolean;
178+
private expandedOffset: number;
179+
180+
constructor(toast:SonnerNotification){
181+
super();
182+
this.settings = toast;
183+
this.life = toast.duration;
184+
this.height = 66;
185+
this.y = 0;
186+
this.offset = 0;
187+
this.index = 0;
188+
this.dead = false;
189+
this.scale = 1;
190+
this.isExpanded = false;
191+
this.expandedOffset = 0;
192+
193+
this.style.setProperty("--y", "0");
194+
this.style.setProperty("--opacity", "1");
195+
this.style.setProperty("--offset", "0px");
196+
this.style.setProperty("--scale", "1");
197+
}
198+
199+
connectedCallback() {
200+
this.innerHTML = `
201+
<copy-wrapper>
202+
${this.renderHeading()}
203+
${this.renderMessage()}
204+
</copy-wrapper>
205+
${this.renderButton()}
206+
`;
207+
const buttonEl = this.querySelector("button");
208+
if (buttonEl) {
209+
buttonEl.addEventListener("click", ()=>{
210+
this.settings.button.callback();
211+
this.delete();
212+
});
213+
}
214+
const bounds = this.getBoundingClientRect();
215+
this.height = bounds.height;
216+
const anim = this.animate([
217+
{ opacity: 0, transform: `translateY(100%)` },
218+
{ opacity: 1, transform: `translateY(0px)` }
219+
], {
220+
duration: 300,
221+
fill: 'forwards',
222+
easing: "cubic-bezier(0.0, 0.0, 0.2, 1)",
223+
});
224+
anim.finished.then(() => {
225+
this.style.setProperty("--y", `${this.y}px`);
226+
this.style.setProperty("--opacity", "1");
227+
anim.cancel();
228+
});
229+
}
230+
231+
private renderHeading() {
232+
if (this.settings.heading?.length) {
233+
return `<h3>${this.settings.heading}</h3>`
234+
}
235+
return ""
236+
}
237+
238+
private renderMessage() {
239+
if (this.settings.message?.length) {
240+
return `<p>${this.settings.message}</p>`
241+
}
242+
return ""
243+
}
244+
245+
private renderButton() {
246+
if (this.settings.button?.label) {
247+
// @ts-ignore
248+
return `<button class="${this.settings.button.classes.join(' ')}">${this.settings.button.label}</button>`;
249+
}
250+
return "";
251+
}
252+
253+
public expand(bottomOffset:number) {
254+
this.isExpanded = true;
255+
this.expandedOffset = bottomOffset;
256+
this.style.setProperty("--offset", `${0}px`);
257+
this.style.setProperty("--scale", "1");
258+
this.style.setProperty("--y", `-${bottomOffset}px`);
259+
}
260+
261+
public collapse() {
262+
this.style.setProperty("--offset", `-${this.offset}px`);
263+
this.style.setProperty("--scale", `${this.scale}`);
264+
this.style.setProperty("--y", `${this.y}px`);
265+
}
266+
267+
public updateIndex(index:number) {
268+
this.index = index;
269+
switch(index) {
270+
case 0:
271+
this.style.zIndex = "3";
272+
break;
273+
case 1:
274+
this.style.zIndex = "2";
275+
break;
276+
case 2:
277+
this.style.zIndex = "1";
278+
break;
279+
}
280+
this.offset = 16 * this.index;
281+
this.scale = 1 - (0.05 * index);
282+
this.style.setProperty("--offset", `-${this.offset}px`);
283+
this.style.setProperty("--scale", `${this.scale}`);
284+
}
285+
286+
public update(dt:number) {
287+
this.life -= dt;
288+
if (this.life <= 0 && this.isConnected && !this.dead) {
289+
this.dead = true;
290+
this.delete();
291+
}
292+
}
293+
294+
public delete() {
295+
this.dead = true;
296+
let anim;
297+
if (this.isExpanded) {
298+
this.style.transformOrigin = "center";
299+
anim = this.animate([
300+
{ opacity: 1, },
301+
{ opacity: 0, }
302+
], {
303+
duration: 150,
304+
fill: 'forwards',
305+
easing: "cubic-bezier(0.4, 0.0, 1, 1)",
306+
});
307+
} else {
308+
this.style.setProperty("--offset", `-${this.offset}px`);
309+
anim = this.animate([
310+
{ opacity: 1, transform: `scale(${this.scale}) translateY(${this.y}px) translateY(-${this.offset}px)` },
311+
{ opacity: 0, transform: `scale(${this.scale - 0.05}) translateY(${this.y}px) translateY(-${this.offset}px)` }
312+
], {
313+
duration: 200,
314+
fill: 'forwards',
315+
easing: "cubic-bezier(0.4, 0.0, 1, 1)",
316+
});
317+
}
318+
anim.finished.then(() => {
319+
this.remove();
320+
});
321+
}
322+
}
323+
if (!customElements.get("sonner-toast-component")) {
324+
customElements.define("sonner-toast-component", SonnerToast);
325+
}

src/types.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,24 @@ export type ToastNotification = {
3838
duration: number,
3939
classes: string | string[],
4040
}
41+
42+
export type SonnerNotification = {
43+
heading: string,
44+
message: string,
45+
el: SonnerToast,
46+
duration: number,
47+
classes: Array<string>|string,
48+
button: {
49+
callback: Function,
50+
label: string,
51+
classes: Array<string>|string,
52+
}
53+
}
54+
55+
export interface SonnerToast extends HTMLElement {
56+
expand: (offset:number)=>void,
57+
collapse: Function,
58+
delete: ()=>void,
59+
update: (dt:number)=>void,
60+
updateIndex: (idx:number)=>void,
61+
}

0 commit comments

Comments
 (0)