Skip to content

Commit 0d7f6e7

Browse files
committed
[Feature] Add Toast Stimulus controllers + docs stub
- toast_controller: per-item lifecycle, hover-pause, swipe-to-dismiss, Escape key, force-dismiss + restart events. - toaster_controller: stack/expand-on-hover, MutationObserver for Turbo Stream appends, window.RubyUI.toast JS API (success/error/ warning/info/loading/dismiss/promise), hotkey to focus region. - toast_docs.rb stub. - Register toast in dependencies.yml.
1 parent 6002ffe commit 0d7f6e7

4 files changed

Lines changed: 407 additions & 0 deletions

File tree

gem/lib/generators/ruby_ui/dependencies.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ select:
9595
js_packages:
9696
- "@floating-ui/dom"
9797

98+
toast:
99+
js_packages: []
100+
98101
tooltip:
99102
js_packages:
100103
- "@floating-ui/dom"
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
const SWIPE_THRESHOLD = 45
4+
const TIME_BEFORE_UNMOUNT = 200
5+
6+
// Connects to data-controller="ruby-ui--toast"
7+
export default class extends Controller {
8+
static values = {
9+
duration: { type: Number, default: 4000 },
10+
dismissible: { type: Boolean, default: true },
11+
invert: { type: Boolean, default: false },
12+
onDismiss: String,
13+
onAutoClose: String,
14+
}
15+
16+
connect() {
17+
this._timer = null
18+
this._startedAt = 0
19+
this._remaining = this.durationValue
20+
this._paused = false
21+
this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
22+
23+
this._onPointerDown = this._onPointerDown.bind(this)
24+
this._onPointerMove = this._onPointerMove.bind(this)
25+
this._onPointerUp = this._onPointerUp.bind(this)
26+
this._onPointerEnter = () => this._pause()
27+
this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
28+
this._onKeyDown = this._onKeyDown.bind(this)
29+
this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
30+
this._onRestart = () => this._restart()
31+
this._onRegionPause = () => this._pause()
32+
this._onRegionResume = () => this._resume()
33+
34+
this.element.addEventListener("pointerdown", this._onPointerDown)
35+
this.element.addEventListener("pointerenter", this._onPointerEnter)
36+
this.element.addEventListener("pointerleave", this._onPointerLeave)
37+
this.element.addEventListener("keydown", this._onKeyDown)
38+
this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
39+
this.element.addEventListener("ruby-ui:toast:restart", this._onRestart)
40+
document.addEventListener("ruby-ui:toast:pause", this._onRegionPause)
41+
document.addEventListener("ruby-ui:toast:resume", this._onRegionResume)
42+
43+
requestAnimationFrame(() => {
44+
this.element.dataset.state = "open"
45+
this._start()
46+
})
47+
}
48+
49+
disconnect() {
50+
this._clearTimer()
51+
this.element.removeEventListener("pointerdown", this._onPointerDown)
52+
this.element.removeEventListener("pointerenter", this._onPointerEnter)
53+
this.element.removeEventListener("pointerleave", this._onPointerLeave)
54+
this.element.removeEventListener("keydown", this._onKeyDown)
55+
this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
56+
this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
57+
document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause)
58+
document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume)
59+
}
60+
61+
dismiss(e) {
62+
e?.preventDefault()
63+
if (!this.dismissibleValue) return
64+
this._close("dismiss")
65+
}
66+
67+
_close(reason) {
68+
if (this.element.dataset.state === "closing") return
69+
this.element.dataset.state = "closing"
70+
this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
71+
setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
72+
}
73+
74+
_start() {
75+
if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
76+
this._startedAt = performance.now()
77+
this._remaining = this.durationValue
78+
this._timer = setTimeout(() => this._close("auto"), this._remaining)
79+
}
80+
81+
_restart() {
82+
this._clearTimer()
83+
this._start()
84+
}
85+
86+
_pause() {
87+
if (this._paused || !this._timer) return
88+
this._paused = true
89+
clearTimeout(this._timer)
90+
this._timer = null
91+
this._remaining -= performance.now() - this._startedAt
92+
}
93+
94+
_resume() {
95+
if (!this._paused) return
96+
this._paused = false
97+
if (this._remaining <= 0) return this._close("auto")
98+
this._startedAt = performance.now()
99+
this._timer = setTimeout(() => this._close("auto"), this._remaining)
100+
}
101+
102+
_clearTimer() {
103+
if (this._timer) clearTimeout(this._timer)
104+
this._timer = null
105+
}
106+
107+
_onKeyDown(e) {
108+
if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e)
109+
}
110+
111+
_onPointerDown(e) {
112+
if (!this.dismissibleValue) return
113+
if (e.target.closest("button")) return
114+
try { this.element.setPointerCapture(e.pointerId) } catch {}
115+
this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }
116+
this.element.dataset.swipe = "start"
117+
this.element.addEventListener("pointermove", this._onPointerMove)
118+
this.element.addEventListener("pointerup", this._onPointerUp)
119+
this.element.addEventListener("pointercancel", this._onPointerUp)
120+
}
121+
122+
_onPointerMove(e) {
123+
const dx = e.clientX - this._swipe.x
124+
const dy = e.clientY - this._swipe.y
125+
this.element.dataset.swipe = "move"
126+
this.element.style.transform = `translate(${dx}px, ${dy}px)`
127+
}
128+
129+
_onPointerUp(e) {
130+
const dx = e.clientX - this._swipe.x
131+
const dy = e.clientY - this._swipe.y
132+
const dist = Math.hypot(dx, dy)
133+
const dt = performance.now() - this._swipe.startedAt
134+
const velocity = dist / Math.max(dt, 1)
135+
this.element.removeEventListener("pointermove", this._onPointerMove)
136+
this.element.removeEventListener("pointerup", this._onPointerUp)
137+
this.element.removeEventListener("pointercancel", this._onPointerUp)
138+
this._swipe.active = false
139+
if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
140+
this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
141+
this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
142+
this.element.dataset.swipe = "end"
143+
this.element.style.transform = ""
144+
this._close("dismiss")
145+
} else {
146+
this.element.dataset.swipe = "cancel"
147+
this.element.style.transform = ""
148+
this._resume()
149+
}
150+
}
151+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastDocs < Phlex::HTML
5+
def view_template
6+
div(class: "space-y-4") do
7+
h2 { "Toast" }
8+
p { "Hotwire-native sonner port. Mount once; trigger via Turbo Stream or window.RubyUI.toast." }
9+
end
10+
end
11+
end
12+
end

0 commit comments

Comments
 (0)