Skip to content

Commit b4f5dfb

Browse files
authored
feat: toast web component
feat: toast web component
2 parents 5faf929 + cedca8f commit b4f5dfb

File tree

8 files changed

+312
-96
lines changed

8 files changed

+312
-96
lines changed

internal/ui/app.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import "htmx.org";
22
import Alpine from "alpinejs";
3+
import initToastComponent from "./components/toast/toast";
34

45
window.Alpine = Alpine;
56

7+
initToastComponent();
8+
69
Alpine.start();

internal/ui/components/toast/toast.templ

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,37 @@ package toast
33
const toastContainerID = "toast-container"
44

55
templ Container() {
6-
<div id={ toastContainerID } class="toast toast-top toast-center"></div>
6+
<div id={ toastContainerID }></div>
77
}
88

9-
templ toast() {
10-
<div id={ toastContainerID } hx-swap-oob="afterbegin">
11-
<div
12-
x-data="{
13-
timeoutId: 0,
14-
close() { $el.remove();}
15-
}"
16-
x-init="timeoutId = setTimeout(() => { close() }, 5000);"
17-
@click="clearTimeout(timeoutId); close();"
18-
>
9+
templ toast(toastType string) {
10+
<div id={ toastContainerID } class="toast-container" hx-swap-oob="afterbegin">
11+
<toast-element type={toastType}>
1912
{ children... }
20-
</div>
13+
</toast-element>
2114
</div>
2215
}
2316

2417
templ success(msg string) {
25-
@toast() {
26-
<div class="alert alert-success">
27-
<span>{ msg }</span>
28-
</div>
18+
@toast("success") {
19+
<span>{ msg }</span>
2920
}
3021
}
3122

3223
templ info(msg string) {
33-
@toast() {
34-
<div class="alert alert-info">
35-
<span>{ msg }</span>
36-
</div>
24+
@toast("info") {
25+
<span>{ msg }</span>
3726
}
3827
}
3928

4029
templ warning(msg string) {
41-
@toast() {
42-
<div class="alert alert-warning">
43-
<span>{ msg }</span>
44-
</div>
30+
@toast("warning") {
31+
<span>{ msg }</span>
4532
}
4633
}
4734

4835
templ err(msg string) {
49-
@toast() {
50-
<div class="alert alert-error">
51-
<span>{ msg }</span>
52-
</div>
36+
@toast("error") {
37+
<span>{ msg }</span>
5338
}
5439
}
Lines changed: 193 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,201 @@
1+
const ALERT_TYPE_CLASSES: Record<string, string> = {
2+
"success": "alert-success",
3+
"info": "alert-info",
4+
"warning": "alert-warning",
5+
"error": "alert-error",
6+
}
7+
8+
const DEFAULT_TOAST_DURATION = 5000;
9+
const TOAST_SWIPE_VELOCITY_THRESHOLD = window.screen.availWidth / 2; // px / s
10+
11+
// recreating:
12+
// x-data="{
13+
// timeoutId: 0,
14+
// close() { $el.remove();}
15+
// }"
16+
// x-init="timeoutId = setTimeout(() => { close() }, 5000);"
17+
// @click="clearTimeout(timeoutId); close();"
18+
//
19+
// additional features: progress bar, pause on hover, swiping away on mobile
20+
121
class Toast extends HTMLElement {
2-
constructor() {
3-
super();
4-
this.attachShadow({ mode: "open" });
22+
constructor() {
23+
super();
24+
this.#children = Array.from(this.children).map(el=>el.cloneNode(true));
25+
}
26+
27+
#progressAnimation: Animation | undefined;
28+
#lastTouchEvent: TouchEvent | undefined;
29+
// [px, timestamp]
30+
#lastTouchChanges: number[][] = [];
31+
#children: Node[];
32+
33+
#getAlertElement = (): HTMLElement => {
34+
return <HTMLElement>this.children[0];
35+
}
36+
37+
#parseLeft = (): number => {
38+
const currentLeft = this.#getAlertElement().style.left;
39+
if(currentLeft !== ""){
40+
return parseInt(currentLeft.slice(0, -2))
41+
}
42+
return 0;
43+
}
44+
45+
connectedCallback() {
46+
this.render();
47+
48+
const durationAttr = this.getAttribute("duration") || "";
49+
let toastDuration = parseInt(durationAttr);
50+
if (Number.isNaN(toastDuration)) {
51+
toastDuration = DEFAULT_TOAST_DURATION;
52+
}
53+
54+
this.#progressAnimation = this.querySelector(".toast-alert-progress")!.animate([
55+
{
56+
width: "0%"
57+
},
58+
{
59+
width: "100%"
60+
}
61+
], {
62+
duration: toastDuration,
63+
fill: "forwards"
64+
});
65+
66+
this.#progressAnimation?.addEventListener("finish", ()=>this.triggerClose(0));
67+
68+
this.addEventListener("mouseenter", this.onMouseEnter);
69+
this.addEventListener("mouseleave", this.onMouseLeave);
70+
this.addEventListener("click", ()=>this.triggerClose(0));
71+
this.addEventListener("touchstart", this.onTouchStart);
72+
this.addEventListener("touchmove", this.onTouchMove);
73+
this.addEventListener("touchend", this.onTouchEnd);
74+
}
75+
76+
disconnectedCallback(){
77+
this.removeEventListener("mouseenter", this.onMouseEnter);
78+
this.removeEventListener("mouseleave", this.onMouseLeave);
79+
this.removeEventListener("click", ()=>this.triggerClose(0));
80+
this.removeEventListener("touchstart", this.onTouchStart);
81+
this.removeEventListener("touchmove", this.onTouchMove);
82+
this.removeEventListener("touchend", this.onTouchEnd);
83+
}
84+
85+
onMouseEnter = () => {
86+
this.#progressAnimation?.pause();
87+
}
88+
89+
onMouseLeave = () => {
90+
this.#progressAnimation?.play();
91+
}
92+
93+
onTouchStart = (e: TouchEvent) => {
94+
this.#lastTouchEvent = e;
95+
this.#lastTouchChanges = [];
96+
this.#progressAnimation?.pause();
97+
}
98+
99+
onTouchMove = (e: TouchEvent) => {
100+
let diffX = e.changedTouches[0].clientX - this.#lastTouchEvent!.changedTouches[0].clientX;
101+
102+
// reset lastTouchChanges when the direction changes so that swiping in one direction and then another still closes
103+
// the toast
104+
if(this.#lastTouchChanges.length > 0 && this.#lastTouchChanges[this.#lastTouchChanges.length - 1][0] * diffX < 0){
105+
this.#lastTouchChanges = [];
5106
}
6107

7-
connectedCallback() {
8-
this.render();
108+
// saving x traveled and current timestamp for velocity calculations
109+
this.#lastTouchChanges.push([diffX, performance.now()]);
110+
111+
this.#lastTouchEvent = e;
112+
this.#lastTouchChanges = this.#lastTouchChanges.slice(-5);
113+
this.#getAlertElement().style.left = `${diffX + this.#parseLeft()}px`;
114+
}
115+
116+
onTouchEnd = () => {
117+
const left = this.#parseLeft();
118+
if(this.#lastTouchChanges.length > 0){
119+
// calculate total x movement
120+
const x = this.#lastTouchChanges.reduce((cur, prev) => cur + prev[0], 0);
121+
// calculate timespan
122+
const t = performance.now() - this.#lastTouchChanges[0][1];
123+
// calculate velocity
124+
const v = x / t;
125+
126+
if(Math.abs(v * 1000) >= TOAST_SWIPE_VELOCITY_THRESHOLD) return this.triggerClose(v);
9127
}
10128

11-
render() {
12-
this.shadowRoot.innerHTML = `
13-
<style>
14-
/* Add your styles here */
15-
</style>
16-
<div class="toast">
17-
<slot></slot>
18-
</div>
19-
`;
129+
this.#progressAnimation?.play();
130+
this.#getAlertElement().style.left = "";
131+
this.#getAlertElement().animate([
132+
{
133+
left: `${left}px`
134+
},
135+
{
136+
left: `0px`
137+
}
138+
], 250);
139+
}
140+
141+
triggerClose = (velocity: number) => {
142+
if(!this.#progressAnimation) return;
143+
144+
// we dont want to close the toast if there is text selected
145+
const sel = window.getSelection();
146+
if(sel && sel.rangeCount > 0 && sel.type === "Range"){
147+
if(this.contains(sel?.focusNode)) return;
148+
for(let i = 0; i < sel.rangeCount; i++){
149+
if(this.contains(sel.getRangeAt(i).startContainer)) return;
150+
}
20151
}
152+
153+
this.#progressAnimation = undefined;
154+
155+
const left = this.#parseLeft();
156+
const animationDuration = 250;
157+
158+
this.#getAlertElement().animate([
159+
{
160+
left: `${left}px`
161+
},
162+
{
163+
// velocity is in px per ms
164+
left: `${left + (velocity * animationDuration)}px`
165+
}
166+
], {
167+
duration: animationDuration,
168+
fill: "forwards"
169+
});
170+
this.animate([
171+
{
172+
scale: 1,
173+
opacity: 1
174+
},
175+
{
176+
scale: 0.9,
177+
opacity: 0
178+
}
179+
], {
180+
duration: animationDuration,
181+
easing: "ease-out",
182+
fill: "forwards"
183+
}).addEventListener("finish", () => {
184+
this.remove();
185+
});
186+
}
187+
188+
render() {
189+
const alertType = this.getAttribute('type') || "";
190+
this.innerHTML = `
191+
<div class="toast-alert ${!!ALERT_TYPE_CLASSES[alertType] ? ALERT_TYPE_CLASSES[alertType] : ''}">
192+
<div class="toast-alert-progress"></div>
193+
</div>
194+
`;
195+
this.#getAlertElement().prepend(...this.#children);
196+
}
21197
}
22198

23-
customElements.define("toast-container", Toast);
199+
export default function initToastComponent() {
200+
customElements.define("toast-element", Toast);
201+
}

0 commit comments

Comments
 (0)