Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions audit/component_map/toast.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
shadcn: toast
shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f
kind: popover
docs_view: toast
parts:
Toast: null
ToastRegion: toast-viewport
ToastItem: toast
ToastIcon: toast-icon
ToastTitle: toast-title
ToastDescription: toast-description
ToastAction: toast-action
ToastCancel: null
ToastClose: toast-close
selectors:
ToastRegion: '#ruby-ui-toaster-region'
ToastItem: '#ruby-ui-toaster > [data-slot="toast"]'
ToastTitle: '#ruby-ui-toaster [data-slot="toast-title"]'
ToastDescription: '#ruby-ui-toaster [data-slot="toast-description"]'
ToastClose: '#ruby-ui-toaster [data-slot="toast-close"]'
ToastIcon: null
ToastAction: null
props:
ToastRegion:
limit: max
timeout: duration
toastManager: null
ToastItem:
id: id
timeout: duration
type: variant
priority: priority
onClose: on_dismiss
onRemove: on_remove
actionProps: null
rubyui_only:
ToastRegion: [position, expand, gap, close_button, hotkey, swipe_directions, flash]
ToastItem: [dismissible]
ToastAction: [label, "on"]
ToastCancel: [label]
ToastIcon: [variant]
fixture_shadcn: [button]
45 changes: 45 additions & 0 deletions audit/divergences/toast.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
- component: toast
dimension: ARIA
match: {part: ToastClose, attribute: snapshot, rubyui: "- button \"Close toast\"", shadcn: ""}
rubyui: "close button always exposed to assistive tech"
shadcn: "close button aria-hidden while the toast is neither expanded nor focused (Base UI ToastClose)"
reason: "RubyUI keeps the X in the accessibility tree: it is a real, focusable control, and hiding it until focus enters the toast makes it undiscoverable when browsing the region. The toast is still announced through the single polite region."
since: "2026-11-04"
- component: toast
dimension: ARIA
match: {part: ToastItem, attribute: snapshot, rubyui: "- dialog \"Event created\":\n - heading \"Event created\" [level=2]\n - paragraph: Sunday, December 3 at 9:00 AM\n - button \"Close toast\"", shadcn: "- dialog \"Event created\":\n - heading \"Event created\" [level=2]\n - paragraph: Sunday, December 3 at 9:00 AM"}
rubyui: "toast dialog lists the Close toast button"
shadcn: "toast dialog without the (aria-hidden) close button"
reason: "Follows from the ToastClose entry above: the only difference is the exposed close button."
since: "2026-11-04"
- component: toast
dimension: ARIA
match: {part: ToastRegion, attribute: snapshot, rubyui: "- region \"Notifications\":\n - dialog \"Event created\":\n - heading \"Event created\" [level=2]\n - paragraph: Sunday, December 3 at 9:00 AM\n - button \"Close toast\"", shadcn: "- region \"Notifications\":\n - dialog \"Event created\":\n - heading \"Event created\" [level=2]\n - paragraph: Sunday, December 3 at 9:00 AM"}
rubyui: "region tree lists the Close toast button"
shadcn: "region tree without the (aria-hidden) close button"
reason: "Follows from the ToastClose entry above."
since: "2026-11-04"
- component: toast
dimension: HTML
rubyui: "ToastCancel: a ghost sm button that dismisses the toast"
shadcn: "no cancel part"
reason: "Kept from RubyUI's earlier sonner-style API (cancel: in the JS API, ToastCancel in Phlex) so existing callers keep working; it renders only when asked for."
since: "2026-11-04"
- component: toast
dimension: Behavior
rubyui: "clicking the action button closes the toast"
shadcn: "the action's onClick decides (the example calls toast.close(id))"
reason: "Closing on action is the common case and what RubyUI callers already rely on; server-rendered actions (Stimulus descriptors) have no handle to close the toast themselves."
since: "2026-11-04"
- component: toast
dimension: Props
rubyui: "one global manager (window.RubyUI.toast); actions via action:/ToastAction"
shadcn: "toastManager (createToastManager) and actionProps on toast.add"
reason: "Multiple managers are a React context pattern; Stimulus toasts go through the single region on the page. action: { label, onClick } and ToastAction cover actionProps."
since: "2026-11-04"
- component: toast
dimension: Props
rubyui: "position: (six corners/edges), expand:, gap:, hotkey:, close_button:, swipe_directions:, flash:"
shadcn: "Toaster renders a bottom-right viewport only; hotkey fixed to F6"
reason: "RubyUI extras with Base UI's defaults (bottom-right, F6, gap 12, close shown, swipe down/right); flash: renders Rails flash on load."
since: "2026-11-04"
3 changes: 3 additions & 0 deletions audit/fixtures/toast/content.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
trigger: Show Toast
title: Event created
description: Sunday, December 3 at 9:00 AM
24 changes: 24 additions & 0 deletions audit/fixtures/toast/rubyui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

module ShadcnAuditFixtures
# RubyUI side of audit/fixtures/toast (strings in content.yml).
# duration: Infinity (RubyUI) / timeout: 0 (shadcn) keeps the toast up while collect observes it.
class Toast < Phlex::HTML
include RubyUI

SHOW_TOAST = <<~JS
document.querySelector("[data-audit-trigger]").addEventListener("click", () => {
window.RubyUI.toast("Event created", {
description: "Sunday, December 3 at 9:00 AM",
duration: Infinity
})
})
JS

def view_template
Button(variant: :outline, data: {audit_trigger: true}) { "Show Toast" }
ToastRegion()
script { raw(safe(SHOW_TOAST)) }
end
end
end
31 changes: 31 additions & 0 deletions audit/fixtures/toast/shadcn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// timeout: 0 (shadcn) / duration: Infinity (RubyUI) keeps the toast up while collect observes it.
// window.__auditToast exposes the toast manager to the browser-driven checks,
// as RubyUI's window.RubyUI.toast (fixture shim).
import { useEffect } from "react"

import { Button } from "@/components/ui/button"
import { toast, Toaster } from "@/components/ui/toast"

export default function ToastFixture() {
useEffect(() => {
;(window as unknown as { __auditToast: typeof toast }).__auditToast = toast
}, [])

return (
<Toaster>
<Button
variant="outline"
data-audit-trigger=""
onClick={() =>
toast.add({
title: "Event created",
description: "Sunday, December 3 at 9:00 AM",
timeout: 0,
})
}
>
Show Toast
</Button>
</Toaster>
)
}
4 changes: 2 additions & 2 deletions docs/app/controllers/docs/toast_demo_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,12 @@ def initialize(variant:, title:, description:, action_label: nil)
def view_template
render RubyUI::ToastItem.new(variant: @variant) do
render RubyUI::ToastIcon.new(variant: @variant)
div(class: "flex flex-col gap-1 flex-1 min-w-0") do
div(class: "flex min-w-0 flex-1 flex-col gap-1") do
render RubyUI::ToastTitle.new { @title }
render(RubyUI::ToastDescription.new { @description }) if @description
end
if @action_label
render RubyUI::ToastAction.new(label: @action_label, on: "click->ruby-ui--toast#dismiss")
render RubyUI::ToastAction.new(label: @action_label)
end
render RubyUI::ToastClose.new
end
Expand Down
5 changes: 3 additions & 2 deletions docs/app/javascript/controllers/toast_demo_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const PRESETS = {
text_only: { variant: "default", title: "Event has been created" },
close_button: { variant: "default", title: "Event has been created", description: "Close it manually with the X.", closeButton: true },
close_action: { variant: "default", title: "Event has been created", description: "Friday at 3:00 PM", closeButton: true, action: { label: "Undo" } },
no_close: { variant: "default", title: "Event has been created", description: "No X on this one.", closeButton: false },
}

export default class extends Controller {
Expand All @@ -20,10 +21,10 @@ export default class extends Controller {

if (kind === "promise") {
t.promise(
new Promise((r) => setTimeout(() => r({ name: "Sonner" }), 1500)),
new Promise((r) => setTimeout(() => r({ name: "Event" }), 1500)),
{
loading: "Loading...",
success: (data) => `${data.name} toast has been added`,
success: (data) => `${data.name} created.`,
error: "Error",
}
)
Expand Down
84 changes: 45 additions & 39 deletions docs/app/views/docs/toast.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ class Views::Docs::Toast < Views::Base
{key: "promise", label: "Promise", title: nil},
{key: "text_only", label: "Text Only", title: "Event has been created"},
{key: "close_button", label: "Close Button", title: "Event has been created"},
{key: "close_action", label: "Close + Action", title: "Event has been created"}
{key: "close_action", label: "Close + Action", title: "Event has been created"},
{key: "no_close", label: "No Close Button", title: "Event has been created"}
].freeze

POSITIONS = %w[top-left top-center top-right bottom-left bottom-center bottom-right].freeze
Expand Down Expand Up @@ -42,7 +43,8 @@ def view_template
["promise", "Promise"],
["text_only", "Text Only"],
["close_button", "Close Button"],
["close_action", "Close + Action"]
["close_action", "Close + Action"],
["no_close", "No Close Button"]
].each do |key, label|
div(class: "rounded-md border p-6 flex items-center justify-center min-h-[100px]") do
button(
Expand All @@ -60,14 +62,8 @@ def view_template
p(class: "text-muted-foreground text-sm leading-relaxed") do
plain "Trigger toasts from the server with Turbo Streams or from JavaScript via "
code(class: "rounded bg-muted px-1.5 py-0.5 text-xs") { "window.RubyUI.toast.*" }
plain ". Heavily inspired by the original "
a(
href: "https://github.com/emilkowalski/sonner",
target: "_blank",
rel: "noopener",
class: "underline underline-offset-2 hover:text-foreground"
) { "sonner" }
plain "."
plain ". Mirrors shadcn's Toast (Base UI): a polite live region, toasts that stack, "
plain "pause while hovered or focused, and swipe away. Press F6 to move focus to the notifications, Tab to reach the newest toast and Escape to dismiss it."
end

Heading(level: 2) { "Mount" }
Expand All @@ -78,6 +74,10 @@ def view_template

# Pass flash to render Rails flash on initial load:
render RubyUI::ToastRegion.new(flash: helpers.flash.to_h)

# The region is a popover="manual": a toast fired while a modal
# Dialog/Sheet is open shows above it (it becomes clickable once
# the modal closes, since the page behind a modal is inert).
RUBY
end

Expand Down Expand Up @@ -116,8 +116,12 @@ def view_template
render turbo_stream: turbo_stream.append("ruby-ui-toaster") {
render RubyUI::ToastItem.new(variant: :success) do
render RubyUI::ToastIcon.new(variant: :success)
render RubyUI::ToastTitle.new { "Saved" }
render RubyUI::ToastDescription.new { "Project updated." }
div(class: "flex min-w-0 flex-1 flex-col gap-1") do
render RubyUI::ToastTitle.new { "Saved" }
render RubyUI::ToastDescription.new { "Project updated." }
end
render RubyUI::ToastAction.new(label: "Undo", on: "click->projects#undo")
render RubyUI::ToastClose.new
end
}
RUBY
Expand All @@ -135,7 +139,8 @@ def view_template
RubyUI.toast.info("Heads up")
RubyUI.toast.warning("Storage almost full")
RubyUI.toast.loading("Working...")
RubyUI.toast.dismiss(id) // no-arg: dismiss all
RubyUI.toast("Saved", { priority: "high" }) // announced right away
RubyUI.toast.dismiss(id) // no-arg: dismiss all (alias: close)
RubyUI.toast.promise(p, { loading, success, error })

// Equivalent CustomEvent (any source can dispatch this):
Expand All @@ -151,7 +156,7 @@ def view_template

Heading(level: 2) { "API Reference" }

Heading(level: 3) { "Toaster (Region)" }
Heading(level: 3) { "ToastRegion (Toaster)" }
props_table(REGION_PROPS)

Heading(level: 3) { "ToastItem" }
Expand All @@ -170,41 +175,42 @@ def view_template
end

REGION_PROPS = [
{name: "position", default: ":bottom_right", values: ":top_left | :top_center | :top_right | :bottom_left | :bottom_center | :bottom_right", description: "Where the toaster mounts on the viewport."},
{name: "expand", default: "false", values: "Boolean", description: "Always show items expanded (no stack peek)."},
{name: "max", default: "3", values: "Integer", description: "Max visible toasts before oldest auto-evicts."},
{name: "duration", default: "4000", values: "Integer (ms)", description: "Default lifetime per toast. Pass 0 or Infinity to disable auto-dismiss."},
{name: "gap", default: "14", values: "Integer (px)", description: "Spacing between toasts when expanded."},
{name: "offset", default: "24", values: "Integer (px)", description: "Distance from the viewport edge."},
{name: "theme", default: ":system", values: ":system | :light | :dark", description: "Color scheme override."},
{name: "rich_colors", default: "false", values: "Boolean", description: "Enable variant-tinted backgrounds."},
{name: "close_button", default: "false", values: "Boolean", description: "Render an X button in every toast (top-right)."},
{name: "hotkey", default: "%w[alt t]", values: "Array<String>", description: "Keyboard shortcut to focus the first toast."},
{name: "dir", default: ":ltr", values: ":ltr | :rtl", description: "Text direction."},
{name: "position", default: ":bottom_right", values: ":top_left | :top_center | :top_right | :bottom_left | :bottom_center | :bottom_right", description: "Where the toaster mounts on the viewport (shadcn's is bottom-right)."},
{name: "expand", default: "false", values: "Boolean", description: "Always show toasts expanded (no stack peek)."},
{name: "max", default: "3", values: "Integer", description: "Toasts shown at once (Base UI limit). Older ones stay hidden and come back when a front toast closes."},
{name: "duration", default: "5000", values: "Integer (ms) | Float::INFINITY", description: "Default lifetime per toast (Base UI timeout). 0 or Float::INFINITY keeps toasts until dismissed."},
{name: "gap", default: "12", values: "Integer (px)", description: "Spacing between toasts when expanded."},
{name: "close_button", default: "true", values: "Boolean", description: "Render the X button in every toast."},
{name: "hotkey", default: "%w[F6]", values: "Array<String>", description: "Shortcut that moves focus to the notifications region (Tab then reaches the newest toast), e.g. %w[alt t]."},
{name: "swipe_directions", default: "(from position)", values: "Array<:up | :down | :left | :right>", description: "Directions a swipe can dismiss in. Defaults to down/right for bottom-right; [] disables swipe."},
{name: "flash", default: "nil", values: "Hash | nil", description: "Pass `helpers.flash.to_h` to render Rails flash on initial load."}
].freeze

ITEM_PROPS = [
{name: "variant", default: ":default", values: ":default | :success | :error | :warning | :info | :loading", description: "Visual + a11y role + icon."},
{name: "variant", default: ":default", values: ":default | :success | :error | :warning | :info | :loading", description: "Icon and data-type."},
{name: "priority", default: ":low", values: ":low | :high", description: "High-priority toasts are announced right away (role=alert) and use role=alertdialog."},
{name: "id", default: "nil", values: "String", description: "DOM id; auto-generated when not provided."},
{name: "duration", default: "nil", values: "Integer | nil", description: "Override the Region default. nil inherits."},
{name: "dismissible", default: "true", values: "Boolean", description: "Allow Escape, swipe, X, and force-dismiss to close."},
{name: "invert", default: "false", values: "Boolean", description: "Invert background/foreground (light-on-dark in light theme)."},
{name: "on_dismiss", default: "nil", values: "String", description: "Stimulus action descriptor fired when the user dismisses."},
{name: "on_auto_close", default: "nil", values: "String", description: "Stimulus action descriptor fired when the timer expires."}
{name: "duration", default: "nil", values: "Integer | Float::INFINITY | nil", description: "Override the region default. nil inherits it; 0 or Float::INFINITY stays until dismissed."},
{name: "dismissible", default: "true", values: "Boolean", description: "Allow Escape, swipe, the X and Cancel to close it. RubyUI.toast.dismiss() closes it either way."},
{name: "on_dismiss", default: "nil", values: "String", description: "Stimulus action (\"controller#method\") run on ruby-ui:toast:close. The controller must be on the toast or an ancestor."},
{name: "on_remove", default: "nil", values: "String", description: "Stimulus action run on ruby-ui:toast:remove, after the exit animation."}
].freeze

JS_OPTIONS = [
{name: "title", default: "—", values: "String", description: "Headline text. Falls back to the first positional argument."},
{name: "description", default: "—", values: "String", description: "Secondary line under the title."},
{name: "duration", default: "(Region default)", values: "Number | Infinity", description: "ms before auto-dismiss. Infinity = sticky."},
{name: "action", default: "—", values: "{ label, onClick }", description: "Primary action button rendered inside the toast."},
{name: "cancel", default: "—", values: "{ label, onClick }", description: "Secondary dismiss button."},
{name: "closeButton", default: "false", values: "Boolean", description: "Force an X close button on this toast."},
{name: "position", default: "(Region default)", values: "String", description: "Per-toast position override (changes Region's data-position before append)."},
{name: "id", default: "(auto)", values: "String", description: "Set a stable id (used by .dismiss(id) and .promise)."},
{name: "dismissible", default: "true", values: "Boolean", description: "Disable Escape / swipe / dismiss-all for this toast."},
{name: "className", default: "—", values: "String", description: "Extra classes appended to the rendered <li>."}
{name: "duration", default: "(region default)", values: "Number | Infinity", description: "ms before auto-dismiss. 0 or Infinity = stays until dismissed."},
{name: "priority", default: "\"low\"", values: "\"low\" | \"high\"", description: "\"high\" is announced right away."},
{name: "action", default: "—", values: "{ label, onClick }", description: "Action button (outline). Clicking it closes the toast."},
{name: "cancel", default: "—", values: "{ label, onClick }", description: "Secondary dismiss button (RubyUI extra)."},
{name: "closeButton", default: "(region default)", values: "Boolean", description: "Show or hide the X on this toast."},
{name: "onClose", default: "—", values: "Function", description: "Called when the toast starts closing ({ id, reason })."},
{name: "onRemove", default: "—", values: "Function", description: "Called after the exit animation, when the toast leaves the DOM."},
{name: "swipeDirections", default: "(region default)", values: "Array<String>", description: "Directions a swipe can dismiss this toast in."},
{name: "position", default: "(region default)", values: "String", description: "Moves the region before this toast is added."},
{name: "id", default: "(auto)", values: "String", description: "Stable id (used by .dismiss(id) and .promise)."},
{name: "dismissible", default: "true", values: "Boolean", description: "false turns off Escape, swipe, the X and Cancel for this toast; RubyUI.toast.dismiss() still closes it."},
{name: "className", default: "—", values: "String", description: "Extra classes on the toast element."}
].freeze

private
Expand Down
3 changes: 3 additions & 0 deletions gem/lib/generators/ruby_ui/dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ sidebar:
- "Tooltip"

toast:
components:
- "Button"

js_packages: []

tooltip:
Expand Down
Loading
Loading