From c32495a9a5b1aabca5e16d11f7728073c06e587d Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 11:36:55 +0000 Subject: [PATCH 01/11] update daisyui dependency --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d73caac..585c6ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@tailwindcss/cli": "^4.1.12", "@types/alpinejs": "^3.13.11", "bun": "^1.2.21", - "daisyui": "^5.0.51", + "daisyui": "^5.4.4", "tailwindcss": "^4.1.12" } }, @@ -923,9 +923,9 @@ } }, "node_modules/daisyui": { - "version": "5.0.51", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.51.tgz", - "integrity": "sha512-lhB0BBOjt43/t5S1my0XChMy3ClXfmGlDU/XmSlx+N0h2y7cyWF+cnheeemguxNHb9TjqI66mxKI9qiFsOU3mA==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.4.4.tgz", + "integrity": "sha512-FEfdwmGdb3ZbtYN3OqaZn/RMarlZTPOv6mWpGUo7KX42RkcBanEZk5tgYp2ZgirKl7QvH8pCiwpuy55wi2dHyQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index f1c911c..54c66bb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@tailwindcss/cli": "^4.1.12", "@types/alpinejs": "^3.13.11", "bun": "^1.2.21", - "daisyui": "^5.0.51", + "daisyui": "^5.4.4", "tailwindcss": "^4.1.12" }, "dependencies": { From 0414f94353317ddce2531e86e62b3258f9c6f82f Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 12:57:27 +0000 Subject: [PATCH 02/11] migrate toast element to web components, add progress bar + pausing on hover --- internal/ui/app.ts | 3 + internal/ui/components/toast/toast.templ | 41 +++---- internal/ui/components/toast/toast.ts | 119 +++++++++++++++++--- internal/ui/components/toast/toast_templ.go | 109 ++++++++++-------- internal/ui/styles.css | 27 +++++ internal/ui/util/global-styles.ts | 19 ++++ tsconfig.json | 10 ++ 7 files changed, 235 insertions(+), 93 deletions(-) create mode 100644 internal/ui/util/global-styles.ts create mode 100644 tsconfig.json diff --git a/internal/ui/app.ts b/internal/ui/app.ts index 9575ab3..44745db 100644 --- a/internal/ui/app.ts +++ b/internal/ui/app.ts @@ -1,6 +1,9 @@ import "htmx.org"; import Alpine from "alpinejs"; +import initToastComponent from "./components/toast/toast"; window.Alpine = Alpine; +initToastComponent(); + Alpine.start(); diff --git a/internal/ui/components/toast/toast.templ b/internal/ui/components/toast/toast.templ index 48f1540..38e88e3 100644 --- a/internal/ui/components/toast/toast.templ +++ b/internal/ui/components/toast/toast.templ @@ -3,52 +3,37 @@ package toast const toastContainerID = "toast-container" templ Container() { -
+
} -templ toast() { -
-
+templ toast(toastType string) { +
+ { children... } -
+
} templ success(msg string) { - @toast() { -
- { msg } -
+ @toast("success") { + { msg } } } templ info(msg string) { - @toast() { -
- { msg } -
+ @toast("info") { + { msg } } } templ warning(msg string) { - @toast() { -
- { msg } -
+ @toast("warning") { + { msg } } } templ err(msg string) { - @toast() { -
- { msg } -
+ @toast("error") { + { msg } } } diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 3cde401..50b44ee 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -1,23 +1,108 @@ +import {getGlobalStyleSheets} from "../../util/global-styles"; + +const ALERT_TYPE_CLASSES: Record = { + "success": "alert-success", + "info": "alert-info", + "warning": "alert-warning", + "error": "alert-error", +} + +const DEFAULT_TOAST_DURATION = 5000; + +// recreating: +// x-data="{ +// timeoutId: 0, +// close() { $el.remove();} +// }" +// x-init="timeoutId = setTimeout(() => { close() }, 5000);" +// @click="clearTimeout(timeoutId); close();" +// +// additional features: progress bar, pause on hover +// TODO: mobile swiping + class Toast extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: "open" }); - } + constructor() { + super(); + this.attachShadow({mode: "open"}); + this.shadowRoot!.adoptedStyleSheets.push(...getGlobalStyleSheets()); + } - connectedCallback() { - this.render(); - } + #progressAnimation: Animation | undefined; + + connectedCallback() { + this.render(); - render() { - this.shadowRoot.innerHTML = ` - -
- -
- `; + const durationAttr = this.getAttribute("duration") || ""; + let toastDuration = parseInt(durationAttr); + if (Number.isNaN(toastDuration)) { + toastDuration = DEFAULT_TOAST_DURATION; } + + this.#progressAnimation = this.shadowRoot!.querySelector(".toast-alert-progress")!.animate([ + { + width: "0%" + }, + { + width: "100%" + } + ], { + duration: toastDuration, + fill: "forwards" + }); + + this.#progressAnimation?.addEventListener("finish", this.triggerClose); + + this.addEventListener("mouseenter", this.onMouseEnter); + this.addEventListener("mouseleave", this.onMouseLeave); + this.addEventListener("click", this.triggerClose); + } + + disconnectedCallback(){ + this.removeEventListener("mouseenter", this.onMouseEnter); + this.removeEventListener("mouseleave", this.onMouseLeave); + this.removeEventListener("click", this.triggerClose); + } + + onMouseEnter = () => { + this.#progressAnimation?.pause(); + } + + onMouseLeave = () => { + this.#progressAnimation?.play(); + } + + triggerClose = () => { + if(!this.#progressAnimation) return; + this.#progressAnimation = undefined; + this.animate([ + { + scale: 1, + opacity: 1 + }, + { + scale: 0.9, + opacity: 0 + } + ], { + duration: 250, + easing: "ease-out", + fill: "forwards" + }).addEventListener("finish", () => { + this.remove(); + }); + } + + render() { + const alertType = this.getAttribute('type') || ""; + this.shadowRoot!.innerHTML = ` +
+ +
+
+ `; + } } -customElements.define("toast-container", Toast); +export default function initToastComponent() { + customElements.define("toast-element", Toast); +} \ No newline at end of file diff --git a/internal/ui/components/toast/toast_templ.go b/internal/ui/components/toast/toast_templ.go index bd780a6..6da5ab8 100644 --- a/internal/ui/components/toast/toast_templ.go +++ b/internal/ui/components/toast/toast_templ.go @@ -44,7 +44,7 @@ func Container() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" class=\"toast toast-top toast-center\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -52,7 +52,7 @@ func Container() templ.Component { }) } -func toast() templ.Component { +func toast(toastType string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -86,7 +86,20 @@ func toast() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-swap-oob=\"afterbegin\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" class=\"toast-container\" hx-swap-oob=\"afterbegin\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -94,7 +107,7 @@ func toast() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -118,12 +131,12 @@ func success(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var5 := templ.GetChildren(ctx) - if templ_7745c5c3_Var5 == nil { - templ_7745c5c3_Var5 = templ.NopComponent + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -135,26 +148,26 @@ func success(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(msg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 27, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 19, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = toast().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = toast("success").Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -178,12 +191,12 @@ func info(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var10 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -195,26 +208,26 @@ func info(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(msg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 35, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 25, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = toast().Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = toast("info").Render(templ.WithChildren(ctx, templ_7745c5c3_Var10), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -238,12 +251,12 @@ func warning(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var12 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -255,26 +268,26 @@ func warning(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(msg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 43, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 31, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = toast().Render(templ.WithChildren(ctx, templ_7745c5c3_Var12), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = toast("warning").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -298,12 +311,12 @@ func err(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var15 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -315,26 +328,26 @@ func err(msg string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(msg) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(msg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 51, Col: 14} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/ui/components/toast/toast.templ`, Line: 37, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = toast().Render(templ.WithChildren(ctx, templ_7745c5c3_Var15), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = toast("error").Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/ui/styles.css b/internal/ui/styles.css index e4f365c..4d4b892 100644 --- a/internal/ui/styles.css +++ b/internal/ui/styles.css @@ -3,6 +3,33 @@ themes: light --default, dark --prefersdark; } +/*alert-[status] isn't found in source very easily, so best to force add to css*/ +/*reference: https://tailwindcss.com/docs/detecting-classes-in-source-files#safelisting-specific-utilities*/ +@source inline("alert-success"); +@source inline("alert-warning"); +@source inline("alert-error"); +@source inline("alert-info"); + [x-cloak] { display: none !important; } + +@layer components{ + .toast-alert{ + @apply alert alert-vertical sm:alert-horizontal relative overflow-hidden; + } + + .toast-alert-progress{ + @apply h-1 absolute; + + + bottom: calc(0% - var(--border)); + left: calc(0% - var(--border)); + z-index: 10; + background: currentColor; + } + + #toast-container{ + @apply toast sm:toast-top flex-col-reverse sm:flex-col toast-center z-50; + } +} \ No newline at end of file diff --git a/internal/ui/util/global-styles.ts b/internal/ui/util/global-styles.ts new file mode 100644 index 0000000..0a463df --- /dev/null +++ b/internal/ui/util/global-styles.ts @@ -0,0 +1,19 @@ +let sheets: undefined | CSSStyleSheet[]; + +export function getGlobalStyleSheets(): CSSStyleSheet[]{ + if (sheets === undefined){ + sheets = []; + for(let i = 0; i < document.styleSheets.length; i++){ + const domSheet = document.styleSheets.item(i); + const sheet = new CSSStyleSheet(); + let css = ""; + for(let j = 0; j < domSheet!.cssRules.length; j++){ + css += domSheet!.cssRules.item(j)!.cssText; + } + sheet.replaceSync(css); + sheets.push(sheet); + } + } + + return sheets; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..76c0e8b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} From 983262b5c788c412073b702a68d95330e9e85b84 Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 14:59:48 +0000 Subject: [PATCH 03/11] allow swiping of toasts --- internal/ui/components/toast/toast.ts | 88 ++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 50b44ee..14cc8e3 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -8,6 +8,7 @@ const ALERT_TYPE_CLASSES: Record = { } const DEFAULT_TOAST_DURATION = 5000; +const TOAST_SWIPE_VELOCITY_THRESHOLD = window.screen.availWidth / 2; // px / s // recreating: // x-data="{ @@ -17,8 +18,7 @@ const DEFAULT_TOAST_DURATION = 5000; // x-init="timeoutId = setTimeout(() => { close() }, 5000);" // @click="clearTimeout(timeoutId); close();" // -// additional features: progress bar, pause on hover -// TODO: mobile swiping +// additional features: progress bar, pause on hover, swiping away on mobile class Toast extends HTMLElement { constructor() { @@ -28,6 +28,9 @@ class Toast extends HTMLElement { } #progressAnimation: Animation | undefined; + #lastTouchEvent: TouchEvent | undefined; + // [px, timestamp] + #lastTouchChanges: number[][] = []; connectedCallback() { this.render(); @@ -50,17 +53,31 @@ class Toast extends HTMLElement { fill: "forwards" }); - this.#progressAnimation?.addEventListener("finish", this.triggerClose); + this.#progressAnimation?.addEventListener("finish", ()=>this.triggerClose(0)); this.addEventListener("mouseenter", this.onMouseEnter); this.addEventListener("mouseleave", this.onMouseLeave); - this.addEventListener("click", this.triggerClose); + this.addEventListener("click", ()=>this.triggerClose(0)); + this.addEventListener("touchstart", this.onTouchStart); + this.addEventListener("touchmove", this.onTouchMove); + this.addEventListener("touchend", this.onTouchEnd); } disconnectedCallback(){ this.removeEventListener("mouseenter", this.onMouseEnter); this.removeEventListener("mouseleave", this.onMouseLeave); - this.removeEventListener("click", this.triggerClose); + this.removeEventListener("click", ()=>this.triggerClose(0)); + this.removeEventListener("touchstart", this.onTouchStart); + this.removeEventListener("touchmove", this.onTouchMove); + this.removeEventListener("touchend", this.onTouchEnd); + } + + #parseLeft = (): number => { + const currentLeft = this.shadowRoot!.children[0]!.style.left; + if(currentLeft !== ""){ + return parseInt(currentLeft.slice(0, -2)) + } + return 0; } onMouseEnter = () => { @@ -71,9 +88,66 @@ class Toast extends HTMLElement { this.#progressAnimation?.play(); } - triggerClose = () => { + onTouchStart = (e: TouchEvent) => { + this.#lastTouchEvent = e; + this.#lastTouchChanges = []; + this.#progressAnimation?.pause(); + } + + onTouchMove = (e: TouchEvent) => { + let diffX = e.changedTouches[0].clientX - this.#lastTouchEvent!.changedTouches[0].clientX; + // saving x traveled and current timestamp for velocity calculations + this.#lastTouchChanges.push([diffX, performance.now()]); + + this.#lastTouchEvent = e; + this.#lastTouchChanges.slice(-5); + this.shadowRoot!.children[0]!.style.left = `${diffX + this.#parseLeft()}px`; + } + + onTouchEnd = () => { + const left = this.#parseLeft(); + if(this.#lastTouchChanges.length > 2){ + // calculate total x movement + const x = this.#lastTouchChanges.reduce((cur, prev) => cur + prev[0], 0); + // calculate timespan + const t = performance.now() - this.#lastTouchChanges[0][1]; + // calculate velocity + const v = x / t; + + if(Math.abs(v * 1000) >= TOAST_SWIPE_VELOCITY_THRESHOLD) return this.triggerClose(v); + } + + this.#progressAnimation?.play(); + this.shadowRoot!.children[0]!.style.left = ""; + this.shadowRoot!.children[0]!.animate([ + { + left: `${left}px` + }, + { + left: `0px` + } + ], 250); + } + + triggerClose = (velocity: number) => { if(!this.#progressAnimation) return; this.#progressAnimation = undefined; + + const left = this.#parseLeft(); + const animationDuration = 250; + + this.shadowRoot!.children[0]!.animate([ + { + left: `${left}px` + }, + { + // velocity is in px per ms + left: `${left + (velocity * animationDuration)}px` + } + ], { + duration: animationDuration, + fill: "forwards" + }); this.animate([ { scale: 1, @@ -84,7 +158,7 @@ class Toast extends HTMLElement { opacity: 0 } ], { - duration: 250, + duration: animationDuration, easing: "ease-out", fill: "forwards" }).addEventListener("finish", () => { From 1b3ba1c19ed249556caa1c4e0ca6a7c6ee27fe42 Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:04:56 +0000 Subject: [PATCH 04/11] reset lastTouchChanges when direction changes --- internal/ui/components/toast/toast.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 14cc8e3..c49e879 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -97,6 +97,11 @@ class Toast extends HTMLElement { onTouchMove = (e: TouchEvent) => { let diffX = e.changedTouches[0].clientX - this.#lastTouchEvent!.changedTouches[0].clientX; // saving x traveled and current timestamp for velocity calculations + + if(this.#lastTouchChanges.length > 0 && this.#lastTouchChanges[this.#lastTouchChanges.length - 1][0] * diffX < 0){ + this.#lastTouchChanges = []; + } + this.#lastTouchChanges.push([diffX, performance.now()]); this.#lastTouchEvent = e; From 134aaac2bb6feda0dacccd410c94a29715f61784 Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:05:13 +0000 Subject: [PATCH 05/11] move helper functions, add #getAlertElement for casting to HTMLElement --- internal/ui/components/toast/toast.ts | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index c49e879..0993f9f 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -32,6 +32,18 @@ class Toast extends HTMLElement { // [px, timestamp] #lastTouchChanges: number[][] = []; + #getAlertElement = (): HTMLElement => { + return this.shadowRoot!.children[0]; + } + + #parseLeft = (): number => { + const currentLeft = this.#getAlertElement().style.left; + if(currentLeft !== ""){ + return parseInt(currentLeft.slice(0, -2)) + } + return 0; + } + connectedCallback() { this.render(); @@ -72,14 +84,6 @@ class Toast extends HTMLElement { this.removeEventListener("touchend", this.onTouchEnd); } - #parseLeft = (): number => { - const currentLeft = this.shadowRoot!.children[0]!.style.left; - if(currentLeft !== ""){ - return parseInt(currentLeft.slice(0, -2)) - } - return 0; - } - onMouseEnter = () => { this.#progressAnimation?.pause(); } @@ -106,7 +110,7 @@ class Toast extends HTMLElement { this.#lastTouchEvent = e; this.#lastTouchChanges.slice(-5); - this.shadowRoot!.children[0]!.style.left = `${diffX + this.#parseLeft()}px`; + this.#getAlertElement().style.left = `${diffX + this.#parseLeft()}px`; } onTouchEnd = () => { @@ -123,8 +127,8 @@ class Toast extends HTMLElement { } this.#progressAnimation?.play(); - this.shadowRoot!.children[0]!.style.left = ""; - this.shadowRoot!.children[0]!.animate([ + this.#getAlertElement().style.left = ""; + this.#getAlertElement().animate([ { left: `${left}px` }, @@ -140,8 +144,8 @@ class Toast extends HTMLElement { const left = this.#parseLeft(); const animationDuration = 250; - - this.shadowRoot!.children[0]!.animate([ + + this.#getAlertElement().animate([ { left: `${left}px` }, From d061b9275bca9c7dea9c4abfe2d4ec3f1bc8a96f Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:07:05 +0000 Subject: [PATCH 06/11] mroe comments --- internal/ui/components/toast/toast.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 0993f9f..8f6c944 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -100,12 +100,14 @@ class Toast extends HTMLElement { onTouchMove = (e: TouchEvent) => { let diffX = e.changedTouches[0].clientX - this.#lastTouchEvent!.changedTouches[0].clientX; - // saving x traveled and current timestamp for velocity calculations + // reset lastTouchChanges when the direction changes so that swiping in one direction and then another still closes + // the toast if(this.#lastTouchChanges.length > 0 && this.#lastTouchChanges[this.#lastTouchChanges.length - 1][0] * diffX < 0){ this.#lastTouchChanges = []; } + // saving x traveled and current timestamp for velocity calculations this.#lastTouchChanges.push([diffX, performance.now()]); this.#lastTouchEvent = e; From b2716f14c1b1ae2ee646c0e7f749186e1fb9e0aa Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:23:45 +0000 Subject: [PATCH 07/11] dont close toast while selecting text within it --- internal/ui/components/toast/toast.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 8f6c944..46bd902 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -142,6 +142,16 @@ class Toast extends HTMLElement { triggerClose = (velocity: number) => { if(!this.#progressAnimation) return; + + // we dont want to close the toast if there is text selected + const sel = window.getSelection(); + if(sel && sel.rangeCount > 0 && sel.type === "Range"){ + if(this.contains(sel?.focusNode)) return; + for(let i = 0; i < sel.rangeCount; i++){ + if(this.contains(sel.getRangeAt(i).startContainer)) return; + } + } + this.#progressAnimation = undefined; const left = this.#parseLeft(); From 2a6df606dd6c56a4cc0e0dda7bc68814713a25be Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:31:37 +0000 Subject: [PATCH 08/11] > 2 -> > 0 --- internal/ui/components/toast/toast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 46bd902..8a7ca40 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -117,7 +117,7 @@ class Toast extends HTMLElement { onTouchEnd = () => { const left = this.#parseLeft(); - if(this.#lastTouchChanges.length > 2){ + if(this.#lastTouchChanges.length > 0){ // calculate total x movement const x = this.#lastTouchChanges.reduce((cur, prev) => cur + prev[0], 0); // calculate timespan From 69b63f92b92a33cbc0c028681d158ddc6977eabc Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 15:34:24 +0000 Subject: [PATCH 09/11] actually modify array - oops! --- internal/ui/components/toast/toast.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index 8a7ca40..fed9332 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -111,7 +111,7 @@ class Toast extends HTMLElement { this.#lastTouchChanges.push([diffX, performance.now()]); this.#lastTouchEvent = e; - this.#lastTouchChanges.slice(-5); + this.#lastTouchChanges = this.#lastTouchChanges.slice(-5); this.#getAlertElement().style.left = `${diffX + this.#parseLeft()}px`; } From 7b9fbdde92b6666dad1a8bdc093f73dd2131f21d Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 16:44:31 +0000 Subject: [PATCH 10/11] fix: css going behind border this was originally to make it go above the border, i forgot to change it though. --- internal/ui/styles.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ui/styles.css b/internal/ui/styles.css index 4d4b892..4c4a7d2 100644 --- a/internal/ui/styles.css +++ b/internal/ui/styles.css @@ -20,11 +20,11 @@ } .toast-alert-progress{ - @apply h-1 absolute; + @apply h-0.5 absolute; - bottom: calc(0% - var(--border)); - left: calc(0% - var(--border)); + bottom: 0; + left: 0; z-index: 10; background: currentColor; } From cedca8f52c17372588daa45d44194185189643d1 Mon Sep 17 00:00:00 2001 From: solithcy Date: Wed, 5 Nov 2025 17:18:22 +0000 Subject: [PATCH 11/11] don't use shadowroot --- internal/ui/components/toast/toast.ts | 16 +++++++--------- internal/ui/styles.css | 2 +- internal/ui/util/global-styles.ts | 19 ------------------- 3 files changed, 8 insertions(+), 29 deletions(-) delete mode 100644 internal/ui/util/global-styles.ts diff --git a/internal/ui/components/toast/toast.ts b/internal/ui/components/toast/toast.ts index fed9332..813a76e 100644 --- a/internal/ui/components/toast/toast.ts +++ b/internal/ui/components/toast/toast.ts @@ -1,5 +1,3 @@ -import {getGlobalStyleSheets} from "../../util/global-styles"; - const ALERT_TYPE_CLASSES: Record = { "success": "alert-success", "info": "alert-info", @@ -23,17 +21,17 @@ const TOAST_SWIPE_VELOCITY_THRESHOLD = window.screen.availWidth / 2; // px / s class Toast extends HTMLElement { constructor() { super(); - this.attachShadow({mode: "open"}); - this.shadowRoot!.adoptedStyleSheets.push(...getGlobalStyleSheets()); + this.#children = Array.from(this.children).map(el=>el.cloneNode(true)); } #progressAnimation: Animation | undefined; #lastTouchEvent: TouchEvent | undefined; // [px, timestamp] #lastTouchChanges: number[][] = []; + #children: Node[]; #getAlertElement = (): HTMLElement => { - return this.shadowRoot!.children[0]; + return this.children[0]; } #parseLeft = (): number => { @@ -53,7 +51,7 @@ class Toast extends HTMLElement { toastDuration = DEFAULT_TOAST_DURATION; } - this.#progressAnimation = this.shadowRoot!.querySelector(".toast-alert-progress")!.animate([ + this.#progressAnimation = this.querySelector(".toast-alert-progress")!.animate([ { width: "0%" }, @@ -189,15 +187,15 @@ class Toast extends HTMLElement { render() { const alertType = this.getAttribute('type') || ""; - this.shadowRoot!.innerHTML = ` + this.innerHTML = `
-
`; + this.#getAlertElement().prepend(...this.#children); } } export default function initToastComponent() { customElements.define("toast-element", Toast); -} \ No newline at end of file +} diff --git a/internal/ui/styles.css b/internal/ui/styles.css index 4c4a7d2..b0af83b 100644 --- a/internal/ui/styles.css +++ b/internal/ui/styles.css @@ -32,4 +32,4 @@ #toast-container{ @apply toast sm:toast-top flex-col-reverse sm:flex-col toast-center z-50; } -} \ No newline at end of file +} diff --git a/internal/ui/util/global-styles.ts b/internal/ui/util/global-styles.ts deleted file mode 100644 index 0a463df..0000000 --- a/internal/ui/util/global-styles.ts +++ /dev/null @@ -1,19 +0,0 @@ -let sheets: undefined | CSSStyleSheet[]; - -export function getGlobalStyleSheets(): CSSStyleSheet[]{ - if (sheets === undefined){ - sheets = []; - for(let i = 0; i < document.styleSheets.length; i++){ - const domSheet = document.styleSheets.item(i); - const sheet = new CSSStyleSheet(); - let css = ""; - for(let j = 0; j < domSheet!.cssRules.length; j++){ - css += domSheet!.cssRules.item(j)!.cssText; - } - sheet.replaceSync(css); - sheets.push(sheet); - } - } - - return sheets; -} \ No newline at end of file