Skip to content

Commit d88a551

Browse files
committed
[Documentation] Wire Toast into docs site
- Mount RubyUI::ToastRegion globally in application layout. - Add /docs/toast page with usage, variants, server-pushed, JS API, position, and Rails flash bridge examples. - Add Docs::ToastDemoController with turbo_stream.append endpoints (default/success/error/warning/info/with_action) for live demo. - Register Stimulus controllers (ruby-ui--toast, ruby-ui--toaster). - Add Toast to sidebar components list.
1 parent 0d7f6e7 commit d88a551

9 files changed

Lines changed: 576 additions & 0 deletions

File tree

docs/app/components/shared/components_list.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def components
5050
{name: "Tabs", path: docs_tabs_path},
5151
{name: "Textarea", path: docs_textarea_path},
5252
{name: "Theme Toggle", path: docs_theme_toggle_path},
53+
{name: "Toast", path: docs_toast_path},
5354
{name: "Tooltip", path: docs_tooltip_path},
5455
{name: "Typography", path: docs_typography_path}
5556
]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module Docs
4+
class ToastDemoController < ApplicationController
5+
def default = push(:default, "Event scheduled", "Friday at 3:00 PM")
6+
7+
def success = push(:success, "Saved successfully", "Your changes are live.")
8+
9+
def error = push(:error, "Something went wrong", "Please retry.")
10+
11+
def warning = push(:warning, "Heads up", "Storage almost full.")
12+
13+
def info = push(:info, "FYI", "New version available.")
14+
15+
def with_action
16+
render turbo_stream: build_stream(:default, "Email archived", nil, action_label: "Undo")
17+
end
18+
19+
private
20+
21+
def push(variant, title, description)
22+
render turbo_stream: build_stream(variant, title, description)
23+
end
24+
25+
def build_stream(variant, title, description, action_label: nil)
26+
content = ToastFragment.new(
27+
variant: variant,
28+
title: title,
29+
description: description,
30+
action_label: action_label
31+
).call
32+
turbo_stream.append("ruby-ui-toaster", content.html_safe)
33+
end
34+
35+
class ToastFragment < Phlex::HTML
36+
def initialize(variant:, title:, description:, action_label: nil)
37+
@variant = variant
38+
@title = title
39+
@description = description
40+
@action_label = action_label
41+
end
42+
43+
def view_template
44+
render RubyUI::ToastItem.new(variant: @variant) do
45+
render RubyUI::ToastIcon.new(variant: @variant)
46+
div(class: "flex flex-col gap-1 flex-1 min-w-0") do
47+
render RubyUI::ToastTitle.new { @title }
48+
render(RubyUI::ToastDescription.new { @description }) if @description
49+
end
50+
if @action_label
51+
render RubyUI::ToastAction.new(label: @action_label, on: "click->ruby-ui--toast#dismiss")
52+
end
53+
render RubyUI::ToastClose.new
54+
end
55+
end
56+
end
57+
end
58+
end

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ def theme_toggle
222222
render Views::Docs::ThemeToggle.new
223223
end
224224

225+
def toast
226+
render Views::Docs::Toast.new
227+
end
228+
225229
def tooltip
226230
render Views::Docs::Tooltip.new
227231
end

docs/app/javascript/controllers/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ application.register("ruby-ui--tabs", RubyUi__TabsController)
9191
import RubyUi__ThemeToggleController from "./ruby_ui/theme_toggle_controller"
9292
application.register("ruby-ui--theme-toggle", RubyUi__ThemeToggleController)
9393

94+
import RubyUi__ToastController from "./ruby_ui/toast_controller"
95+
application.register("ruby-ui--toast", RubyUi__ToastController)
96+
97+
import RubyUi__ToasterController from "./ruby_ui/toaster_controller"
98+
application.register("ruby-ui--toaster", RubyUi__ToasterController)
99+
94100
import RubyUi__TooltipController from "./ruby_ui/tooltip_controller"
95101
application.register("ruby-ui--tooltip", RubyUi__TooltipController)
96102

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+
}

0 commit comments

Comments
 (0)