Skip to content

Commit 6002ffe

Browse files
committed
[Feature] Add Toast component (sonner port) — Phlex side
Adds RubyUI::Toast{Region,Item,Title,Description,Icon,Action,Cancel,Close} with full variant set (default/success/error/warning/info/loading), inline lucide SVG icons, role/aria mapping, Rails flash bridge, and per-variant hidden <template> skeletons inside a turbo-frame for client-side spawning. No custom CSS — pure Tailwind utilities + tailwindcss-animate.
1 parent c13b7e1 commit 6002ffe

12 files changed

Lines changed: 520 additions & 0 deletions

gem/lib/ruby_ui/toast/toast.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
module Toast
5+
FLASH_VARIANTS = {
6+
"notice" => :info,
7+
"alert" => :warning,
8+
"success" => :success,
9+
"error" => :error,
10+
"warning" => :warning,
11+
"info" => :info
12+
}.freeze
13+
14+
def self.flash_variant(key)
15+
FLASH_VARIANTS[key.to_s] || :default
16+
end
17+
end
18+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastAction < Base
5+
def initialize(label:, on: nil, **attrs)
6+
@label = label
7+
@on = on
8+
super(**attrs)
9+
end
10+
11+
def view_template
12+
button(**attrs) { @label }
13+
end
14+
15+
private
16+
17+
def default_attrs
18+
data = {slot: "action"}
19+
data[:action] = @on if @on
20+
{
21+
type: "button",
22+
data: data,
23+
class: "inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-input bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary hover:text-secondary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
24+
}
25+
end
26+
end
27+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastCancel < Base
5+
def initialize(label:, **attrs)
6+
@label = label
7+
super(**attrs)
8+
end
9+
10+
def view_template
11+
button(**attrs) { @label }
12+
end
13+
14+
private
15+
16+
def default_attrs
17+
{
18+
type: "button",
19+
data: {
20+
slot: "cancel",
21+
action: "click->ruby-ui--toast#dismiss"
22+
},
23+
class: "inline-flex h-8 shrink-0 items-center justify-center rounded-md px-3 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
24+
}
25+
end
26+
end
27+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastClose < Base
5+
def view_template
6+
button(**attrs) do
7+
svg(
8+
xmlns: "http://www.w3.org/2000/svg",
9+
width: "15",
10+
height: "15",
11+
viewbox: "0 0 24 24",
12+
fill: "none",
13+
stroke: "currentColor",
14+
stroke_width: "2",
15+
stroke_linecap: "round",
16+
stroke_linejoin: "round",
17+
class: "size-4"
18+
) do |s|
19+
s.path(d: "M18 6 6 18")
20+
s.path(d: "m6 6 12 12")
21+
end
22+
span(class: "sr-only") { "Close" }
23+
end
24+
end
25+
26+
private
27+
28+
def default_attrs
29+
{
30+
type: "button",
31+
aria_label: "Close toast",
32+
data: {
33+
slot: "close",
34+
action: "click->ruby-ui--toast#dismiss"
35+
},
36+
class: "absolute right-1 top-1 rounded-md p-1 text-foreground/60 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring group-hover/toast:opacity-100"
37+
}
38+
end
39+
end
40+
end
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastDescription < Base
5+
def view_template(&)
6+
div(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{
13+
data: {slot: "description"},
14+
class: "text-sm text-muted-foreground"
15+
}
16+
end
17+
end
18+
end
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastIcon < Base
5+
def initialize(variant: nil, **attrs)
6+
@variant = variant&.to_sym
7+
super(**attrs)
8+
end
9+
10+
def view_template
11+
return unless renderable?
12+
span(**attrs) do
13+
svg(
14+
xmlns: "http://www.w3.org/2000/svg",
15+
width: "16",
16+
height: "16",
17+
viewbox: "0 0 24 24",
18+
fill: "none",
19+
stroke: "currentColor",
20+
stroke_width: "2",
21+
stroke_linecap: "round",
22+
stroke_linejoin: "round",
23+
class: svg_classes
24+
) { |s| paths(s) }
25+
end
26+
end
27+
28+
private
29+
30+
def renderable?
31+
%i[success error warning info loading].include?(@variant)
32+
end
33+
34+
def svg_classes
35+
base = "size-4"
36+
(@variant == :loading) ? "#{base} animate-spin" : base
37+
end
38+
39+
def paths(s)
40+
case @variant
41+
when :success
42+
s.circle(cx: "12", cy: "12", r: "10")
43+
s.path(d: "m9 12 2 2 4-4")
44+
when :error
45+
s.path(d: "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z")
46+
s.path(d: "m15 9-6 6")
47+
s.path(d: "m9 9 6 6")
48+
when :warning
49+
s.path(d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3")
50+
s.path(d: "M12 9v4")
51+
s.path(d: "M12 17h.01")
52+
when :info
53+
s.circle(cx: "12", cy: "12", r: "10")
54+
s.path(d: "M12 16v-4")
55+
s.path(d: "M12 8h.01")
56+
when :loading
57+
s.path(d: "M21 12a9 9 0 1 1-6.219-8.56")
58+
end
59+
end
60+
61+
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}
70+
end
71+
end
72+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class ToastItem < Base
5+
ALERT_VARIANTS = %i[error].freeze
6+
7+
def initialize(
8+
variant: :default,
9+
id: nil,
10+
duration: nil,
11+
dismissible: true,
12+
invert: false,
13+
on_dismiss: nil,
14+
on_auto_close: nil,
15+
**attrs
16+
)
17+
@variant = variant.to_sym
18+
@id = id
19+
@duration = duration
20+
@dismissible = dismissible
21+
@invert = invert
22+
@on_dismiss = on_dismiss
23+
@on_auto_close = on_auto_close
24+
super(**attrs)
25+
end
26+
27+
def view_template(&)
28+
li(**attrs, &)
29+
end
30+
31+
private
32+
33+
def default_attrs
34+
a = {
35+
role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status",
36+
aria_atomic: "true",
37+
tabindex: "0",
38+
data: {
39+
variant: @variant.to_s,
40+
state: "pending",
41+
swipe: "none",
42+
controller: "ruby-ui--toast",
43+
ruby_ui__toast_dismissible_value: @dismissible.to_s,
44+
ruby_ui__toast_invert_value: @invert.to_s
45+
},
46+
class: item_classes
47+
}
48+
a[:id] = @id if @id
49+
a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration
50+
a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss
51+
a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close
52+
a
53+
end
54+
55+
def item_classes
56+
<<~CLASSES.tr("\n", " ").squeeze(" ").strip
57+
group/toast pointer-events-auto relative flex w-[356px] max-w-full items-center gap-3
58+
overflow-hidden rounded-lg border bg-popover text-popover-foreground
59+
border-border p-4 pr-8 shadow-lg
60+
transition-all duration-200 ease-out
61+
will-change-transform
62+
translate-y-[var(--y-offset,0px)] scale-[var(--scale,1)] opacity-[var(--opacity,1)]
63+
data-[state=pending]:opacity-0 data-[state=pending]:translate-y-2
64+
data-[state=open]:animate-in data-[state=open]:fade-in-0
65+
data-[state=closing]:animate-out data-[state=closing]:fade-out-0
66+
data-[swipe=move]:transition-none
67+
data-[swipe=cancel]:translate-x-0
68+
data-[swipe=end]:translate-x-[var(--swipe-end-x,0)]
69+
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
74+
CLASSES
75+
end
76+
end
77+
end

0 commit comments

Comments
 (0)