Skip to content

Commit 6fd51da

Browse files
committed
[Documentation] Match shadcn sonner demo style + visual polish
- Drop variant-tinted borders; icons monochrome (currentColor) — matches shadcn sonner exactly. - Rewrite docs page: shadcn-style "Examples > Types" grid (2-col) with one box per variant, each containing a 'Show toast' button. - Position section: 6-button grid; click spawns toast in chosen corner via new `position` override in spawn detail (toaster_controller swaps data-position before spawning). - JS API section explains it's sugar over a window CustomEvent (Hotwire-friendly: any source can dispatch `ruby-ui:toast`). - Server-pushed example: fix button_to to use form-level data-turbo-stream. - toaster_controller: register window.RubyUI.toast earlier so click handlers don't race with controller connect.
1 parent 128874e commit 6fd51da

6 files changed

Lines changed: 116 additions & 83 deletions

File tree

docs/app/javascript/controllers/ruby_ui/toaster_controller.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ export default class extends Controller {
2424
this._heights = new Map()
2525
this._expanded = this.expandValue
2626
this._listEl = this.element.tagName === "OL" ? this.element : this.element.querySelector("ol")
27-
if (!this._listEl) return
28-
2927
this._registerGlobalApi()
28+
if (!this._listEl) return
3029

3130
this._observer = new MutationObserver((records) => {
3231
for (const r of records) {
@@ -74,6 +73,10 @@ export default class extends Controller {
7473
const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
7574
const tpl = this._skeletonFor(variant)
7675
if (!tpl) return null
76+
if (detail.position) {
77+
this.element.setAttribute("data-position", detail.position)
78+
this.positionValue = detail.position
79+
}
7780
const node = tpl.content.firstElementChild.cloneNode(true)
7881

7982
node.id = detail.id || `toast-${this._uuid()}`

docs/app/javascript/controllers/toast_demo_controller.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,48 @@
11
import { Controller } from "@hotwired/stimulus"
22

3+
const PRESETS = {
4+
default: { variant: "default", title: "Event has been created", description: "Sunday, December 03, 2023 at 9:00 AM" },
5+
success: { variant: "success", title: "Event has been created" },
6+
info: { variant: "info", title: "Be at the area 10 minutes before the event time" },
7+
warning: { variant: "warning", title: "Event start time cannot be earlier than 8am" },
8+
error: { variant: "error", title: "Event has not been created" },
9+
with_action: { variant: "default", title: "Event has been created", action: { label: "Undo" } },
10+
}
11+
312
export default class extends Controller {
413
fire(e) {
5-
const variant = e.params.variant || "default"
14+
const kind = e.params.kind || "default"
615
const t = window.RubyUI?.toast
7-
if (!t) return
8-
const titles = { success: "Saved", error: "Boom", info: "Heads up", warning: "Storage almost full", default: "Hello" }
9-
const descs = { success: "Project updated.", error: "Server returned 500.", info: "New version available.", warning: "Almost out of space.", default: "Just so you know." }
10-
t[variant]?.(titles[variant], { description: descs[variant] })
16+
if (!t) return console.warn("RubyUI.toast not available")
17+
18+
if (kind === "promise") {
19+
t.promise(
20+
new Promise((r) => setTimeout(() => r({ name: "Sonner" }), 1500)),
21+
{
22+
loading: "Loading...",
23+
success: (data) => `${data.name} toast has been added`,
24+
error: "Error",
25+
}
26+
)
27+
return
28+
}
29+
30+
const preset = PRESETS[kind] || PRESETS.default
31+
const opts = { description: preset.description, action: preset.action }
32+
const fn = t[preset.variant] || t
33+
fn(preset.title, opts)
1134
}
1235

13-
promise() {
14-
const p = new Promise((r) => setTimeout(() => r({ id: 42 }), 1500))
15-
window.RubyUI?.toast.promise(p, {
16-
loading: "Saving...",
17-
success: (v) => `Saved (id ${v.id})`,
18-
error: "Failed",
19-
})
36+
position(e) {
37+
const position = e.params.position || "bottom-right"
38+
window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
39+
detail: {
40+
variant: "default",
41+
title: `Position: ${position}`,
42+
description: "Toast spawned in this corner",
43+
position,
44+
}
45+
}))
2046
}
2147

2248
dismissAll() {

docs/app/views/docs/toast.rb

Lines changed: 67 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,67 @@
33
class Views::Docs::Toast < Views::Base
44
include Phlex::Rails::Helpers::ButtonTo
55

6+
EXAMPLES = [
7+
{key: "default", label: "Default", title: "Event has been created", description: "Sunday, December 03, 2023 at 9:00 AM"},
8+
{key: "success", label: "Success", title: "Event has been created"},
9+
{key: "info", label: "Info", title: "Be at the area 10 minutes before the event time"},
10+
{key: "warning", label: "Warning", title: "Event start time cannot be earlier than 8am"},
11+
{key: "error", label: "Error", title: "Event has not been created"},
12+
{key: "with_action", label: "With Action", title: "Event has been created", action_label: "Undo"},
13+
{key: "promise", label: "Promise", title: nil}
14+
].freeze
15+
16+
POSITIONS = %w[top-left top-center top-right bottom-left bottom-center bottom-right].freeze
17+
618
def view_template
719
component = "Toast"
820

921
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
1022
render Docs::Header.new(
1123
title: "Toast",
12-
description: "Hotwire-native sonner port. Push toasts from the server with Turbo Streams or trigger from JavaScript via window.RubyUI.toast."
24+
description: "Toast notifications, Hotwire-native. Trigger from the server with Turbo Streams or from JavaScript via window.RubyUI.toast.*. Heavily inspired by the original sonner: https://github.com/emilkowalski/sonner."
1325
)
1426

15-
Heading(level: 2) { "Usage" }
16-
17-
render Docs::VisualCodeExample.new(title: "Mount in your layout", context: self) do
18-
<<~RUBY
19-
# In application_layout.rb (Phlex) or application.html.erb (ERB), once globally:
27+
Heading(level: 2) { "Mount" }
28+
div(class: "rounded-md border bg-muted/30 p-4") do
29+
Codeblock(<<~RUBY, syntax: :ruby)
30+
# In application_layout.rb (Phlex), once globally:
2031
render RubyUI::ToastRegion.new
32+
33+
# Pass flash to render Rails flash on initial load:
34+
render RubyUI::ToastRegion.new(flash: helpers.flash.to_h)
2135
RUBY
2236
end
2337

24-
Heading(level: 2) { "Variants" }
25-
p(class: "text-muted-foreground text-sm") { "Click any to push a toast from the server (Turbo Stream)." }
26-
div(class: "flex flex-wrap gap-2 mt-2") do
27-
button_to "Default", docs_toast_demo_default_path, data: {turbo_stream: true}, class: button_class
28-
button_to "Success", docs_toast_demo_success_path, data: {turbo_stream: true}, class: button_class
29-
button_to "Error", docs_toast_demo_error_path, data: {turbo_stream: true}, class: button_class
30-
button_to "Warning", docs_toast_demo_warning_path, data: {turbo_stream: true}, class: button_class
31-
button_to "Info", docs_toast_demo_info_path, data: {turbo_stream: true}, class: button_class
32-
button_to "With action", docs_toast_demo_with_action_path, data: {turbo_stream: true}, class: button_class
38+
Heading(level: 2) { "Examples" }
39+
Heading(level: 3) { "Types" }
40+
div(class: "grid gap-4 sm:grid-cols-2", data: {controller: "toast-demo"}) do
41+
EXAMPLES.each { |ex| example_box(ex) }
42+
end
43+
44+
Heading(level: 3) { "Position" }
45+
p(class: "text-muted-foreground text-sm") { "Use the position prop to change where toasts mount." }
46+
div(class: "rounded-md border p-8 flex flex-wrap items-center justify-center gap-2", data: {controller: "toast-demo"}) do
47+
POSITIONS.each do |pos|
48+
button(
49+
type: "button",
50+
class: button_class,
51+
data: {action: "click->toast-demo#position", toast_demo_position_param: pos}
52+
) { pos.split("-").map(&:capitalize).join(" ") }
53+
end
3354
end
3455

3556
Heading(level: 2) { "Server-pushed (Turbo Stream)" }
36-
div(class: "rounded-md border bg-muted/30 p-4") do
57+
p(class: "text-muted-foreground text-sm") { "Append a toast to the global region from any controller." }
58+
div(class: "flex flex-wrap gap-2 mt-2") do
59+
button_to "Push success from server",
60+
docs_toast_demo_success_path,
61+
class: button_class,
62+
form: {data: {turbo_stream: "true"}, class: "inline"}
63+
end
64+
div(class: "rounded-md border bg-muted/30 p-4 mt-4") do
3765
Codeblock(<<~RUBY, syntax: :ruby)
38-
# In your controller, respond with a Turbo Stream that appends
39-
# a Toast::Item to the global region (id="ruby-ui-toaster"):
66+
# In your controller:
4067
render turbo_stream: turbo_stream.append("ruby-ui-toaster") {
4168
render RubyUI::ToastItem.new(variant: :success) do
4269
render RubyUI::ToastIcon.new(variant: :success)
@@ -48,51 +75,26 @@ def view_template
4875
RUBY
4976
end
5077

51-
Heading(level: 2) { "JS API" }
52-
div(class: "flex flex-wrap gap-2 mt-2", data: {controller: "toast-demo"}) do
53-
%w[success error info warning].each do |variant|
54-
button(
55-
type: "button",
56-
class: button_class,
57-
data: {action: "click->toast-demo#fire", toast_demo_variant_param: variant}
58-
) { "toast.#{variant}" }
59-
end
60-
button(type: "button", class: button_class, data: {action: "click->toast-demo#promise"}) { "toast.promise" }
61-
button(type: "button", class: button_class, data: {action: "click->toast-demo#dismissAll"}) { "dismiss all" }
78+
Heading(level: 2) { "JavaScript API" }
79+
p(class: "text-muted-foreground text-sm") do
80+
plain "Hotwire-friendly: window.RubyUI.toast.* is sugar over a CustomEvent dispatch. Either path works."
6281
end
63-
64-
div(class: "rounded-md border bg-muted/30 p-4 mt-4") do
82+
div(class: "rounded-md border bg-muted/30 p-4 mt-2") do
6583
Codeblock(<<~JS, syntax: :javascript)
84+
// Sugar:
6685
RubyUI.toast.success("Saved", { description: "Project updated." })
6786
RubyUI.toast.error("Boom")
6887
RubyUI.toast.info("Heads up")
6988
RubyUI.toast.warning("Storage almost full")
7089
RubyUI.toast.loading("Working...")
71-
RubyUI.toast.dismiss(id) // no-arg dismisses all
90+
RubyUI.toast.dismiss(id) // no-arg: dismiss all
7291
RubyUI.toast.promise(p, { loading, success, error })
73-
JS
74-
end
75-
76-
Heading(level: 2) { "Position" }
77-
render Docs::VisualCodeExample.new(title: "Configurable on the Region", context: self) do
78-
<<~RUBY
79-
render RubyUI::ToastRegion.new(
80-
position: :top_right,
81-
expand: true,
82-
max: 5,
83-
duration: 6000
84-
)
85-
RUBY
86-
end
8792
88-
Heading(level: 2) { "Rails flash bridge" }
89-
render Docs::VisualCodeExample.new(title: "Renders flash on initial load", context: self) do
90-
<<~RUBY
91-
# In your controller:
92-
# flash[:notice] = "Saved"
93-
# In your layout:
94-
render RubyUI::ToastRegion.new(flash: helpers.flash.to_h)
95-
RUBY
93+
// Equivalent CustomEvent (any source can dispatch this):
94+
window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
95+
detail: { variant: "success", title: "Saved", description: "..." }
96+
}))
97+
JS
9698
end
9799

98100
render Components::ComponentSetup::Tabs.new(component_name: component)
@@ -103,7 +105,17 @@ def view_template
103105

104106
private
105107

108+
def example_box(ex)
109+
div(class: "rounded-md border p-8 flex items-center justify-center min-h-[120px]") do
110+
button(
111+
type: "button",
112+
class: button_class,
113+
data: {action: "click->toast-demo#fire", toast_demo_kind_param: ex[:key]}
114+
) { "Show #{ex[:label]} toast" }
115+
end
116+
end
117+
106118
def button_class
107-
"inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-2 text-sm font-medium hover:bg-accent"
119+
"inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
108120
end
109121
end

gem/lib/ruby_ui/toast/toast_icon.rb

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,7 @@ def paths(s)
5959
end
6060

6161
def default_attrs
62-
color = case @variant
63-
when :success then "text-green-600 dark:text-green-500"
64-
when :error then "text-red-600 dark:text-red-500"
65-
when :warning then "text-yellow-600 dark:text-yellow-500"
66-
when :info then "text-blue-600 dark:text-blue-500"
67-
when :loading then "text-muted-foreground"
68-
end
69-
{data: {slot: "icon"}, class: ["shrink-0 inline-flex items-center justify-center", color].compact}
62+
{data: {slot: "icon"}, class: "shrink-0 inline-flex items-center justify-center text-foreground"}
7063
end
7164
end
7265
end

gem/lib/ruby_ui/toast/toast_item.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,6 @@ def item_classes
6767
data-[swipe=cancel]:translate-x-0
6868
data-[swipe=end]:translate-x-[var(--swipe-end-x,0)]
6969
data-[swipe=end]:translate-y-[var(--swipe-end-y,0)]
70-
data-[variant=success]:border-green-500/30
71-
data-[variant=error]:border-red-500/30
72-
data-[variant=warning]:border-yellow-500/30
73-
data-[variant=info]:border-blue-500/30
7470
CLASSES
7571
end
7672
end

gem/lib/ruby_ui/toast/toaster_controller.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ export default class extends Controller {
2424
this._heights = new Map()
2525
this._expanded = this.expandValue
2626
this._listEl = this.element.tagName === "OL" ? this.element : this.element.querySelector("ol")
27-
if (!this._listEl) return
28-
2927
this._registerGlobalApi()
28+
if (!this._listEl) return
3029

3130
this._observer = new MutationObserver((records) => {
3231
for (const r of records) {
@@ -74,6 +73,10 @@ export default class extends Controller {
7473
const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
7574
const tpl = this._skeletonFor(variant)
7675
if (!tpl) return null
76+
if (detail.position) {
77+
this.element.setAttribute("data-position", detail.position)
78+
this.positionValue = detail.position
79+
}
7780
const node = tpl.content.firstElementChild.cloneNode(true)
7881

7982
node.id = detail.id || `toast-${this._uuid()}`

0 commit comments

Comments
 (0)