diff --git a/audit/component_map/toast.yml b/audit/component_map/toast.yml new file mode 100644 index 000000000..c4d1171fa --- /dev/null +++ b/audit/component_map/toast.yml @@ -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] diff --git a/audit/divergences/toast.yml b/audit/divergences/toast.yml new file mode 100644 index 000000000..e02457db9 --- /dev/null +++ b/audit/divergences/toast.yml @@ -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" diff --git a/audit/fixtures/toast/content.yml b/audit/fixtures/toast/content.yml new file mode 100644 index 000000000..449c26921 --- /dev/null +++ b/audit/fixtures/toast/content.yml @@ -0,0 +1,3 @@ +trigger: Show Toast +title: Event created +description: Sunday, December 3 at 9:00 AM diff --git a/audit/fixtures/toast/rubyui.rb b/audit/fixtures/toast/rubyui.rb new file mode 100644 index 000000000..815dbdac3 --- /dev/null +++ b/audit/fixtures/toast/rubyui.rb @@ -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 diff --git a/audit/fixtures/toast/shadcn.tsx b/audit/fixtures/toast/shadcn.tsx new file mode 100644 index 000000000..e40d33ca7 --- /dev/null +++ b/audit/fixtures/toast/shadcn.tsx @@ -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 ( + + + toast.add({ + title: "Event created", + description: "Sunday, December 3 at 9:00 AM", + timeout: 0, + }) + } + > + Show Toast + + + ) +} diff --git a/docs/app/controllers/docs/toast_demo_controller.rb b/docs/app/controllers/docs/toast_demo_controller.rb index be1b73b23..c0aaf6277 100644 --- a/docs/app/controllers/docs/toast_demo_controller.rb +++ b/docs/app/controllers/docs/toast_demo_controller.rb @@ -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 diff --git a/docs/app/javascript/controllers/toast_demo_controller.js b/docs/app/javascript/controllers/toast_demo_controller.js index d85d8dd5d..b3551cbaa 100644 --- a/docs/app/javascript/controllers/toast_demo_controller.js +++ b/docs/app/javascript/controllers/toast_demo_controller.js @@ -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 { @@ -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", } ) diff --git a/docs/app/views/docs/toast.rb b/docs/app/views/docs/toast.rb index cc548ade1..7114b69cb 100644 --- a/docs/app/views/docs/toast.rb +++ b/docs/app/views/docs/toast.rb @@ -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 @@ -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( @@ -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" } @@ -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 @@ -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 @@ -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): @@ -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" } @@ -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", 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", 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 ."} + {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", 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 diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml index b0722c9f8..66ac73952 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -123,6 +123,9 @@ sidebar: - "Tooltip" toast: + components: + - "Button" + js_packages: [] tooltip: diff --git a/gem/lib/ruby_ui/toast/toast_action.rb b/gem/lib/ruby_ui/toast/toast_action.rb index c92ec02f8..37b6b48ce 100644 --- a/gem/lib/ruby_ui/toast/toast_action.rb +++ b/gem/lib/ruby_ui/toast/toast_action.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true module RubyUI + # Primary action: shadcn's ToastAction (Button outline, size sm). Clicking it + # also closes the toast (registered divergence: Base UI leaves that to the + # handler). class ToastAction < Base def initialize(label:, on: nil, **attrs) @label = label @@ -9,19 +12,20 @@ def initialize(label:, on: nil, **attrs) end def view_template - button(**attrs) { @label } + render RubyUI::Button.new(variant: :outline, size: :sm, **button_attrs) { @label } end private + # slot! makes Button replace its own data-slot="button" instead of joining + # it. Added after Base's mix, which would strip the bang. + def button_attrs + attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-action")) + end + def default_attrs - data = {slot: "action"} - data[:action] = @on if @on - { - type: "button", - data: data, - class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50" - } + actions = [@on, "click->ruby-ui--toast#close"].compact + {data: {action: actions.join(" ")}, class: "shrink-0"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_cancel.rb b/gem/lib/ruby_ui/toast/toast_cancel.rb index 88ea415d9..4fc48fb62 100644 --- a/gem/lib/ruby_ui/toast/toast_cancel.rb +++ b/gem/lib/ruby_ui/toast/toast_cancel.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true module RubyUI + # Secondary dismiss button (a RubyUI extra; shadcn's Toast has no cancel). class ToastCancel < Base def initialize(label:, **attrs) @label = label @@ -8,20 +9,19 @@ def initialize(label:, **attrs) end def view_template - button(**attrs) { @label } + render RubyUI::Button.new(variant: :ghost, size: :sm, **button_attrs) { @label } end private + # slot! makes Button replace its own data-slot="button" instead of joining + # it. Added after Base's mix, which would strip the bang. + def button_attrs + attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-cancel")) + end + def default_attrs - { - type: "button", - data: { - slot: "cancel", - action: "click->ruby-ui--toast#dismiss" - }, - class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors" - } + {data: {action: "click->ruby-ui--toast#dismiss"}, class: "shrink-0"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_close.rb b/gem/lib/ruby_ui/toast/toast_close.rb index 6c2151229..539f67bcd 100644 --- a/gem/lib/ruby_ui/toast/toast_close.rb +++ b/gem/lib/ruby_ui/toast/toast_close.rb @@ -1,39 +1,42 @@ # frozen_string_literal: true module RubyUI + # shadcn's ToastClose: a ghost icon-sm Button in the toast row, with a + # larger hit area (after:-inset-2). class ToastClose < Base def view_template - button(**attrs) do + render RubyUI::Button.new(variant: :ghost, size: :icon_sm, **button_attrs) do svg( xmlns: "http://www.w3.org/2000/svg", - width: "14", - height: "14", + width: "24", + height: "24", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", - class: "size-3.5" + aria_hidden: "true" ) do |s| s.path(d: "M18 6 6 18") s.path(d: "m6 6 12 12") end - span(class: "sr-only") { "Close" } end end private + # slot! makes Button replace its own data-slot="button" instead of joining + # it. Added after Base's mix, which would strip the bang. + def button_attrs + attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-close")) + end + def default_attrs { - type: "button", aria_label: "Close toast", - data: { - slot: "close", - action: "click->ruby-ui--toast#dismiss" - }, - class: "absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + data: {action: "click->ruby-ui--toast#dismiss"}, + class: "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground" } end end diff --git a/gem/lib/ruby_ui/toast/toast_controller.js b/gem/lib/ruby_ui/toast/toast_controller.js index 99010a8fe..f70030591 100644 --- a/gem/lib/ruby_ui/toast/toast_controller.js +++ b/gem/lib/ruby_ui/toast/toast_controller.js @@ -1,151 +1,259 @@ import { Controller } from "@hotwired/stimulus" -const SWIPE_THRESHOLD = 45 -const TIME_BEFORE_UNMOUNT = 200 +const SWIPE_THRESHOLD = 40 +const DEFAULT_DURATION = 5000 +const DIRECTIONS = ["up", "down", "left", "right"] + +let uid = 0 // Connects to data-controller="ruby-ui--toast" +// +// One toast: its timer, swipe-to-dismiss and exit. The toaster controller +// (region) owns stacking, limits and pausing; it talks to toasts through +// ruby-ui:toast:* events dispatched on the toast element. export default class extends Controller { static values = { - duration: { type: Number, default: 4000 }, + duration: Number, dismissible: { type: Boolean, default: true }, - invert: { type: Boolean, default: false }, - onDismiss: String, - onAutoClose: String, + swipeDirections: String, } connect() { this._timer = null this._startedAt = 0 - this._remaining = this.durationValue - this._paused = false - this._swipe = { active: false, x: 0, y: 0, startedAt: 0 } + this._remaining = 0 + this._holds = new Set() + this._swipe = null + + this._wireLabels() this._onPointerDown = this._onPointerDown.bind(this) this._onPointerMove = this._onPointerMove.bind(this) this._onPointerUp = this._onPointerUp.bind(this) - this._onPointerEnter = () => this._pause() - this._onPointerLeave = () => { if (!this._swipe.active) this._resume() } - this._onKeyDown = this._onKeyDown.bind(this) - this._onForceDismiss = (e) => { e.stopPropagation(); this._close() } + this._onForceDismiss = (e) => { e.stopPropagation(); this._close("dismiss") } this._onRestart = () => this._restart() - this._onRegionPause = () => this._pause() - this._onRegionResume = () => this._resume() + this._onHold = (e) => this._setHold(e.detail.reason, e.detail.on) + this._onKeyDown = (e) => { + if (e.key === "Escape" && this.element.contains(document.activeElement)) this.dismiss(e) + } this.element.addEventListener("pointerdown", this._onPointerDown) - this.element.addEventListener("pointerenter", this._onPointerEnter) - this.element.addEventListener("pointerleave", this._onPointerLeave) - this.element.addEventListener("keydown", this._onKeyDown) this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) this.element.addEventListener("ruby-ui:toast:restart", this._onRestart) - document.addEventListener("ruby-ui:toast:pause", this._onRegionPause) - document.addEventListener("ruby-ui:toast:resume", this._onRegionResume) + this.element.addEventListener("ruby-ui:toast:hold", this._onHold) + this.element.addEventListener("keydown", this._onKeyDown) - requestAnimationFrame(() => { - this.element.dataset.state = "open" - this._start() - }) + if (this.element.dataset.state === "pending") { + // Two frames: the toaster paints the start position (off-screen) first, + // so the move to the stack position transitions. + this._enterFrame = requestAnimationFrame(() => { + this._enterFrame = requestAnimationFrame(() => { + this._enterFrame = null + this.element.dataset.state = "open" + this.element.dispatchEvent(new CustomEvent("ruby-ui:toast:opened", { bubbles: true })) + }) + }) + } + this._start() } disconnect() { + if (this._enterFrame) cancelAnimationFrame(this._enterFrame) + this._endSwipe({ resume: false }) this._clearTimer() this.element.removeEventListener("pointerdown", this._onPointerDown) - this.element.removeEventListener("pointerenter", this._onPointerEnter) - this.element.removeEventListener("pointerleave", this._onPointerLeave) - this.element.removeEventListener("keydown", this._onKeyDown) this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart) - document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause) - document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume) + this.element.removeEventListener("ruby-ui:toast:hold", this._onHold) + this.element.removeEventListener("keydown", this._onKeyDown) } + // X, Cancel and swipe: respect dismissible. dismiss(e) { e?.preventDefault() if (!this.dismissibleValue) return this._close("dismiss") } - _close(reason) { - if (this.element.dataset.state === "closing") return - this.element.dataset.state = "closing" - this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } })) - setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT) + // The action button always closes the toast. + close(e) { + e?.preventDefault() + this._close("dismiss") } - _start() { - if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return - this._startedAt = performance.now() - this._remaining = this.durationValue - this._timer = setTimeout(() => this._close("auto"), this._remaining) + // aria-labelledby / aria-describedby, as Base UI's Toast.Root does. A + // dialog needs a name: without a title, the description names it, else a + // generic label. Labels the author set are left alone. + _wireLabels() { + const el = this.element + const filled = (node) => node && node.textContent.trim() !== "" + const title = el.querySelector('[data-slot="toast-title"]') + const description = el.querySelector('[data-slot="toast-description"]') + const base = el.id || `ruby-ui-toast-${++uid}` + const named = el.hasAttribute("aria-labelledby") || el.hasAttribute("aria-label") + if (filled(title)) title.id ||= `${base}-title` + if (filled(description)) description.id ||= `${base}-description` + if (!named) { + if (filled(title)) el.setAttribute("aria-labelledby", title.id) + else if (filled(description)) el.setAttribute("aria-labelledby", description.id) + else el.setAttribute("aria-label", "Notification") + } + const describedByTitle = filled(title) || named + if (filled(description) && describedByTitle && !el.hasAttribute("aria-describedby")) { + el.setAttribute("aria-describedby", description.id) + } } - _restart() { + _duration() { + if (this.hasDurationValue) return this.durationValue + const region = this.element.closest('[data-controller~="ruby-ui--toaster"]') + const inherited = Number(region?.getAttribute("data-ruby-ui--toaster-duration-value")) + return Number.isNaN(inherited) || region === null ? DEFAULT_DURATION : inherited + } + + // 0, Infinity and non-numbers mean "stays until dismissed". + _start() { this._clearTimer() - this._start() + const duration = this._duration() + if (!Number.isFinite(duration) || duration <= 0) { this._remaining = Infinity; return } + this._remaining = duration + const region = this.element.closest('[data-controller~="ruby-ui--toaster"]') + if (region?.dataset.paused) this._holds.add("region") + if (this.element.hasAttribute("data-limited")) this._holds.add("limited") + if (this._holds.size === 0) this._run() } - _pause() { - if (this._paused || !this._timer) return - this._paused = true - clearTimeout(this._timer) - this._timer = null - this._remaining -= performance.now() - this._startedAt + _restart() { + this._start() } - _resume() { - if (!this._paused) return - this._paused = false + _run() { + if (!Number.isFinite(this._remaining)) return if (this._remaining <= 0) return this._close("auto") this._startedAt = performance.now() this._timer = setTimeout(() => this._close("auto"), this._remaining) } + _setHold(reason, on) { + const wasHeld = this._holds.size > 0 + if (on) this._holds.add(reason) + else this._holds.delete(reason) + const held = this._holds.size > 0 + if (held && !wasHeld && this._timer) { + this._clearTimer() + this._remaining -= performance.now() - this._startedAt + } else if (!held && wasHeld && this.element.dataset.state !== "closing") { + this._run() + } + } + _clearTimer() { if (this._timer) clearTimeout(this._timer) this._timer = null } - _onKeyDown(e) { - if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e) + _close(reason, exitTransform = null) { + if (this.element.dataset.state === "closing") return + const el = this.element + const detail = { id: el.id, reason } + el.dataset.state = "closing" + this._clearTimer() + this._endSwipe() + el.removeAttribute("data-behind") + el.dispatchEvent(new CustomEvent("ruby-ui:toast:close", { bubbles: true, detail })) + el.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail })) + + const top = el.closest("[data-position]")?.dataset.position?.startsWith("top") + el.style.transform = exitTransform || `translateY(${top ? "-150%" : "150%"})` + + const transforming = () => el.getAnimations().some((a) => a.transitionProperty === "transform") + let removed = false + const remove = () => { + if (removed) return + removed = true + el.removeEventListener("transitionend", onEnd) + el.removeEventListener("transitioncancel", onEnd) + el.dispatchEvent(new CustomEvent("ruby-ui:toast:remove", { bubbles: true, detail })) + el.remove() + } + // A cancelled enter transition also fires here; wait for the exit one. + const onEnd = (e) => { if (e.target === el && e.propertyName === "transform" && !transforming()) remove() } + el.addEventListener("transitionend", onEnd) + el.addEventListener("transitioncancel", onEnd) + // Flush styles so the exit transition starts now; with none running + // (reduced motion, same transform) there is nothing to wait for. + void el.offsetWidth + if (document.hidden || !transforming()) remove() + } + + _directions() { + const own = this.swipeDirectionsValue + const region = this.element.closest('[data-controller~="ruby-ui--toaster"]') + const list = own || region?.dataset.swipeDirections || "down right" + if (list === "none") return [] + return list.split(/\s+/).filter((d) => DIRECTIONS.includes(d)) } _onPointerDown(e) { - if (!this.dismissibleValue) return - if (e.target.closest("button")) return + if (!this.dismissibleValue || e.button !== 0) return + if (e.target.closest("button, a, input, select, textarea")) return + const directions = this._directions() + if (directions.length === 0) return try { this.element.setPointerCapture(e.pointerId) } catch {} - this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId } - this.element.dataset.swipe = "start" + this._swipe = { x: e.clientX, y: e.clientY, startedAt: performance.now(), base: this.element.style.transform, directions, dx: 0, dy: 0 } + this._setHold("swipe", true) this.element.addEventListener("pointermove", this._onPointerMove) this.element.addEventListener("pointerup", this._onPointerUp) this.element.addEventListener("pointercancel", this._onPointerUp) } + // Free movement along allowed directions, resistance along the others. _onPointerMove(e) { - const dx = e.clientX - this._swipe.x - const dy = e.clientY - this._swipe.y + const s = this._swipe + if (!s) return + const rawX = e.clientX - s.x + const rawY = e.clientY - s.y + const allowedX = (rawX > 0 && s.directions.includes("right")) || (rawX < 0 && s.directions.includes("left")) + const allowedY = (rawY > 0 && s.directions.includes("down")) || (rawY < 0 && s.directions.includes("up")) + s.dx = allowedX ? rawX : Math.sign(rawX) * Math.sqrt(Math.abs(rawX)) + s.dy = allowedY ? rawY : Math.sign(rawY) * Math.sqrt(Math.abs(rawY)) this.element.dataset.swipe = "move" - this.element.style.transform = `translate(${dx}px, ${dy}px)` + this.element.style.transform = `translateX(${s.dx}px) translateY(${s.dy}px) ${s.base}` } - _onPointerUp(e) { - const dx = e.clientX - this._swipe.x - const dy = e.clientY - this._swipe.y - const dist = Math.hypot(dx, dy) - const dt = performance.now() - this._swipe.startedAt - const velocity = dist / Math.max(dt, 1) - this.element.removeEventListener("pointermove", this._onPointerMove) - this.element.removeEventListener("pointerup", this._onPointerUp) - this.element.removeEventListener("pointercancel", this._onPointerUp) - this._swipe.active = false - if (dist > SWIPE_THRESHOLD || velocity > 0.5) { - this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`) - this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`) + _onPointerUp() { + const s = this._swipe + if (!s) return + const elapsed = Math.max(performance.now() - s.startedAt, 1) + const exits = { + right: { distance: s.dx, transform: `translateX(calc(${s.dx}px + 150%)) translateY(${s.dy}px) ${s.base}` }, + left: { distance: -s.dx, transform: `translateX(calc(${s.dx}px - 150%)) translateY(${s.dy}px) ${s.base}` }, + down: { distance: s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px + 150%)) ${s.base}` }, + up: { distance: -s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px - 150%)) ${s.base}` }, + } + const direction = s.directions.find((d) => { + const distance = exits[d].distance + return distance > SWIPE_THRESHOLD || (distance > 10 && distance / elapsed > 0.5) + }) + const base = s.base + this._endSwipe() + if (direction) { this.element.dataset.swipe = "end" - this.element.style.transform = "" - this._close("dismiss") + this.element.dataset.swipeDirection = direction + this._close("dismiss", exits[direction].transform) } else { this.element.dataset.swipe = "cancel" - this.element.style.transform = "" - this._resume() + this.element.style.transform = base } } + + _endSwipe({ resume = true } = {}) { + if (!this._swipe) return + this._swipe = null + this.element.removeEventListener("pointermove", this._onPointerMove) + this.element.removeEventListener("pointerup", this._onPointerUp) + this.element.removeEventListener("pointercancel", this._onPointerUp) + if (resume) this._setHold("swipe", false) + else this._holds.delete("swipe") + } } diff --git a/gem/lib/ruby_ui/toast/toast_description.rb b/gem/lib/ruby_ui/toast/toast_description.rb index 955b5d182..7e77c605f 100644 --- a/gem/lib/ruby_ui/toast/toast_description.rb +++ b/gem/lib/ruby_ui/toast/toast_description.rb @@ -1,18 +1,17 @@ # frozen_string_literal: true module RubyUI + # Secondary text; the toast is described by it (aria-describedby, wired by + # the toast controller). Base UI renders a . class ToastDescription < Base def view_template(&) - div(**attrs, &) + p(**attrs, &) end private def default_attrs - { - data: {slot: "description"}, - class: "font-normal leading-[1.4] text-muted-foreground" - } + {data: {slot: "toast-description"}, class: "text-sm text-muted-foreground"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_icon.rb b/gem/lib/ruby_ui/toast/toast_icon.rb index 9d0ee9a4a..bd34306b5 100644 --- a/gem/lib/ruby_ui/toast/toast_icon.rb +++ b/gem/lib/ruby_ui/toast/toast_icon.rb @@ -20,7 +20,8 @@ def view_template stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", - class: "#{svg_classes} -ml-px" + class: svg_classes, + aria_hidden: "true" ) { |s| paths(s) } end end @@ -32,8 +33,11 @@ def renderable? end def svg_classes - base = "size-4" - (@variant == :loading) ? "#{base} animate-spin" : base + case @variant + when :loading then "size-4 animate-spin" + when :error then "size-4 text-destructive" + else "size-4" + end end def paths(s) @@ -59,7 +63,7 @@ def paths(s) end def default_attrs - {data: {slot: "icon"}, class: "shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground"} + {data: {slot: "toast-icon"}, class: "shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_item.rb b/gem/lib/ruby_ui/toast/toast_item.rb index e2052d42c..d0fa008b5 100644 --- a/gem/lib/ruby_ui/toast/toast_item.rb +++ b/gem/lib/ruby_ui/toast/toast_item.rb @@ -1,72 +1,103 @@ # frozen_string_literal: true module RubyUI + # One toast. Mirrors shadcn's Base UI Toast (role=dialog, aria-modal=false, + # named by its title and described by its description) and wraps the block + # in Base UI's ToastContent row. + # + # on_dismiss / on_remove take Stimulus action descriptors ("undo#commit") + # bound to the toast's own ruby-ui:toast:close / ruby-ui:toast:remove events, + # so the named controller must be on the toast or an ancestor. class ToastItem < Base - ALERT_VARIANTS = %i[error].freeze + PRIORITIES = %i[low high].freeze + REMOVED_KWARGS = %i[invert on_auto_close].freeze def initialize( variant: :default, id: nil, duration: nil, dismissible: true, - invert: false, + priority: :low, on_dismiss: nil, - on_auto_close: nil, + on_remove: nil, **attrs ) + removed = attrs.keys & REMOVED_KWARGS + unless removed.empty? + raise ArgumentError, "RubyUI::ToastItem: #{removed.map { |k| "#{k}:" }.join(", ")} " \ + "#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog." + end + @variant = variant.to_sym @id = id @duration = duration @dismissible = dismissible - @invert = invert + @priority = priority.to_sym + raise ArgumentError, "RubyUI::ToastItem: priority must be :low or :high" unless PRIORITIES.include?(@priority) + @on_dismiss = on_dismiss - @on_auto_close = on_auto_close + @on_remove = on_remove super(**attrs) end def view_template(&) - li(**attrs, &) + div(**attrs) do + div(data: {slot: "toast-content"}, class: content_classes, &) + end end private def default_attrs + actions = [] + actions << "ruby-ui:toast:close->#{@on_dismiss}" if @on_dismiss + actions << "ruby-ui:toast:remove->#{@on_remove}" if @on_remove + data = { + slot: "toast", + type: @variant.to_s, + variant: @variant.to_s, + priority: @priority.to_s, + state: "pending", + swipe: "none", + controller: "ruby-ui--toast", + ruby_ui__toaster_target: "toast", + ruby_ui__toast_dismissible_value: @dismissible.to_s + } + data[:action] = actions.join(" ") unless actions.empty? + data[:ruby_ui__toast_duration_value] = @duration.to_s if @duration a = { - role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status", - aria_atomic: "true", + role: (@priority == :high) ? "alertdialog" : "dialog", + aria_modal: "false", tabindex: "0", - data: { - variant: @variant.to_s, - state: "pending", - swipe: "none", - controller: "ruby-ui--toast", - ruby_ui__toaster_target: "toast", - ruby_ui__toast_dismissible_value: @dismissible.to_s, - ruby_ui__toast_invert_value: @invert.to_s - }, + data: data, class: item_classes } a[:id] = @id if @id - a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration - a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss - a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close a end + # Base UI + vega toast root. The stacking transforms, heights and z-index + # come from the toaster controller (inline styles), not CSS variables. def item_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip - group/toast pointer-events-auto absolute left-0 right-0 - flex w-[356px] max-w-full items-center gap-1.5 - rounded-lg border bg-popover text-popover-foreground - border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)] - group-data-[close-button=true]/toaster:pr-10 - transition-[transform,opacity] duration-300 ease-out - will-change-transform - opacity-[var(--opacity,1)] - data-[state=pending]:opacity-0 - data-[state=closing]:opacity-0 + group/toast pointer-events-auto absolute inset-x-0 w-full + rounded-2xl border bg-popover text-popover-foreground shadow-lg + will-change-transform outline-none select-none + focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 + transition-[transform,opacity,height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] + motion-reduce:transition-none data-[swipe=move]:transition-none + data-[state=pending]:[transform:translateY(150%)] + in-data-[position^=top]:data-[state=pending]:[transform:translateY(-150%)] + data-[limited]:opacity-0 data-[limited]:pointer-events-none + after:absolute after:top-full after:left-0 after:h-[13px] after:w-full after:content-[''] + before:absolute before:bottom-full before:left-0 before:h-[13px] before:w-full before:content-[''] CLASSES end + + def content_classes + "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 " \ + "ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[behind]/toast:opacity-0" + end end end diff --git a/gem/lib/ruby_ui/toast/toast_region.rb b/gem/lib/ruby_ui/toast/toast_region.rb index c8cc6e901..427726f82 100644 --- a/gem/lib/ruby_ui/toast/toast_region.rb +++ b/gem/lib/ruby_ui/toast/toast_region.rb @@ -1,42 +1,55 @@ # frozen_string_literal: true module RubyUI + # The toast viewport: a fixed, labelled live region that hosts the toasts. + # Mount it once per page (layout). Mirrors shadcn's Base UI Toaster + # (toast-viewport); positions other than bottom-right are a RubyUI extra. + # + # The region is a popover="manual" so each new toast can lift it into the + # top layer, above a RubyUI Dialog/Sheet opened with showModal(). class ToastRegion < Base SKELETON_VARIANTS = %i[default success error warning info loading].freeze + POSITIONS = %i[top_left top_center top_right bottom_left bottom_center bottom_right].freeze + SWIPE_DIRECTIONS = %i[up down left right].freeze + REMOVED_KWARGS = %i[offset theme rich_colors dir].freeze def initialize( position: :bottom_right, expand: false, max: 3, - duration: 4000, - gap: 14, - offset: 24, - theme: :system, - rich_colors: false, - close_button: false, - hotkey: %w[alt t], - dir: :ltr, + duration: 5000, + gap: 12, + close_button: true, + hotkey: %w[F6], + swipe_directions: nil, flash: nil, **attrs ) + removed = attrs.keys & REMOVED_KWARGS + unless removed.empty? + raise ArgumentError, "RubyUI::ToastRegion: #{removed.map { |k| "#{k}:" }.join(", ")} " \ + "#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog." + end + @position = position.to_sym + raise ArgumentError, "RubyUI::ToastRegion: unknown position #{position.inspect}" unless POSITIONS.include?(@position) + @expand = expand @max = max @duration = duration @gap = gap - @offset = offset - @theme = theme.to_sym - @rich_colors = rich_colors @close_button = close_button @hotkey = hotkey - @dir = dir + @swipe_directions = swipe_directions && Array(swipe_directions).map(&:to_sym) + unknown = Array(@swipe_directions) - SWIPE_DIRECTIONS + raise ArgumentError, "RubyUI::ToastRegion: unknown swipe direction(s) #{unknown.inspect}" unless unknown.empty? @flash = flash super(**attrs) end def view_template(&block) div(**attrs) do - ol(id: "ruby-ui-toaster", class: "pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full") do + div(id: "ruby-ui-toaster", class: "relative") do render_flash if @flash yield(self) if block end @@ -55,21 +68,19 @@ def render_flash variant = RubyUI::Toast.flash_variant(key) render RubyUI::ToastItem.new(variant: variant, id: "flash-#{key}") do render RubyUI::ToastIcon.new(variant: variant) - render RubyUI::ToastTitle.new { message.to_s } + div(class: "flex min-w-0 flex-1 flex-col gap-1") do + render RubyUI::ToastTitle.new { message.to_s } + end + render RubyUI::ToastClose.new if @close_button end end end def skeleton(variant) - template( - data: { - ruby_ui__toaster_target: "skeleton", - variant: variant.to_s - } - ) do + template(data: {ruby_ui__toaster_target: "skeleton", variant: variant.to_s}) do render RubyUI::ToastItem.new(variant: variant) do render RubyUI::ToastIcon.new(variant: variant) - div(class: "flex flex-col gap-0.5 flex-1 min-w-0") do + div(class: "flex min-w-0 flex-1 flex-col gap-1") do render RubyUI::ToastTitle.new render RubyUI::ToastDescription.new end @@ -83,41 +94,51 @@ def slot_template(target_name, &) end def default_attrs + position = @position.to_s.tr("_", "-") + data = { + slot: "toast-viewport", + controller: "ruby-ui--toaster", + turbo_permanent: "", + position: position, + ruby_ui__toaster_position_value: position, + ruby_ui__toaster_expand_value: @expand.to_s, + ruby_ui__toaster_max_value: @max.to_s, + ruby_ui__toaster_duration_value: @duration.to_s, + ruby_ui__toaster_gap_value: @gap.to_s, + ruby_ui__toaster_close_button_value: @close_button.to_s, + ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+") + } + # "none" disables swipe; leaving it unset derives it from the position. + if @swipe_directions + data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.empty? ? "none" : @swipe_directions.join(" ") + end { id: "ruby-ui-toaster-region", role: "region", aria_label: "Notifications", aria_live: "polite", - data: { - controller: "ruby-ui--toaster", - turbo_permanent: "", - close_button: @close_button.to_s, - position: @position.to_s.tr("_", "-"), - ruby_ui__toaster_position_value: @position.to_s.tr("_", "-"), - ruby_ui__toaster_expand_value: @expand.to_s, - ruby_ui__toaster_max_value: @max.to_s, - ruby_ui__toaster_duration_value: @duration.to_s, - ruby_ui__toaster_gap_value: @gap.to_s, - ruby_ui__toaster_offset_value: @offset.to_s, - ruby_ui__toaster_theme_value: @theme.to_s, - ruby_ui__toaster_rich_colors_value: @rich_colors.to_s, - ruby_ui__toaster_close_button_value: @close_button.to_s, - ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+"), - ruby_ui__toaster_dir_value: @dir.to_s - }, + aria_relevant: "additions text", + aria_atomic: "false", + tabindex: "-1", + popover: "manual", + data: data, class: region_classes } end + # Base UI's toast-viewport classes (bottom-right), plus resets for the + # popover UA styles (inset: 0, margin: auto, border, padding, background) + # and the other five positions. def region_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip - group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6 - data-[position=top-left]:top-0 data-[position=top-left]:left-0 - data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2 - data-[position=top-right]:top-0 data-[position=top-right]:right-0 - data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0 - data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2 - data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0 + pointer-events-none fixed z-50 m-0 h-auto w-auto max-w-sm overflow-visible + border-0 bg-transparent p-0 text-inherit outline-none + inset-x-4 mx-auto sm:mx-0 sm:w-full + data-[position^=bottom]:top-auto data-[position^=bottom]:bottom-4 + data-[position^=top]:top-4 data-[position^=top]:bottom-auto + data-[position$=right]:sm:right-4 data-[position$=right]:sm:left-auto + data-[position$=left]:sm:left-4 data-[position$=left]:sm:right-auto + data-[position$=center]:sm:left-1/2 data-[position$=center]:sm:right-auto data-[position$=center]:sm:-translate-x-1/2 CLASSES end end diff --git a/gem/lib/ruby_ui/toast/toast_title.rb b/gem/lib/ruby_ui/toast/toast_title.rb index b48dbfbe1..93b54ae7d 100644 --- a/gem/lib/ruby_ui/toast/toast_title.rb +++ b/gem/lib/ruby_ui/toast/toast_title.rb @@ -1,18 +1,17 @@ # frozen_string_literal: true module RubyUI + # The toast's heading; the toast is named by it (aria-labelledby, wired by + # the toast controller). Base UI renders an . class ToastTitle < Base def view_template(&) - div(**attrs, &) + h2(**attrs, &) end private def default_attrs - { - data: {slot: "title"}, - class: "font-medium leading-normal" - } + {data: {slot: "toast-title"}, class: "text-sm font-medium"} end end end diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index 8b6e2c8f7..5b28f2c03 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -1,6 +1,8 @@ import { Controller } from "@hotwired/stimulus" const VARIANTS = ["default", "success", "error", "warning", "info", "loading"] +const PEEK = 12 +const SCALE_STEP = 0.1 let streamActionRegistered = false @@ -23,90 +25,324 @@ function registerStreamAction() { } // Connects to data-controller="ruby-ui--toaster" +// +// The toast viewport (shadcn's Base UI Toaster): stacks the toasts, limits how +// many show, pauses every timer while the region is hovered or focused (or the +// window is blurred), focuses the region on the hotkey (F6), and spawns +// toasts from window.RubyUI.toast / the ruby-ui:toast event / Turbo Streams. export default class extends Controller { static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"] static values = { position: { type: String, default: "bottom-right" }, expand: { type: Boolean, default: false }, max: { type: Number, default: 3 }, - duration: { type: Number, default: 4000 }, - gap: { type: Number, default: 14 }, - offset: { type: Number, default: 24 }, - theme: { type: String, default: "system" }, - richColors: { type: Boolean, default: false }, - closeButton: { type: Boolean, default: false }, - hotkey: { type: String, default: "alt+t" }, - dir: { type: String, default: "ltr" }, + duration: { type: Number, default: 5000 }, + gap: { type: Number, default: 12 }, + closeButton: { type: Boolean, default: true }, + hotkey: { type: String, default: "F6" }, + swipeDirections: String, } initialize() { - this._expanded = false - this._heights = new Map() + this._hovered = false + this._holds = new Set() + this._heights = new WeakMap() this._resizeObservers = new WeakMap() + this._announcers = new Map() // toast element -> its hidden role=alert } connect() { - this._expanded = this.expandValue - this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) + // Turbo moves this permanent region between pages: start from a clean + // pause state instead of whatever the last page left behind. + this._hovered = this.element.matches(":hover") + this._holds.clear() + delete this.element.dataset.paused + this._listEl = this.element.querySelector("#ruby-ui-toaster") || this.element this._registerGlobalApi() registerStreamAction() - if (!this._listEl) return - - this._onPointerEnter = () => this._setExpanded(true) - this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) } + this._showPopover() + + this._onPointerEnter = () => { this._hovered = true; this._setHold("hover", true); this._reflow() } + this._onPointerLeave = () => { this._hovered = false; this._setHold("hover", false); this._reflow() } + this._onFocusIn = () => { this._setHold("focus", true); this._reflow() } + this._onFocusOut = (e) => { + if (this.element.contains(e.relatedTarget)) return + this._setHold("focus", false) + this._reflow() + } + this._onWindowBlur = (e) => { if (e.target === window) this._setHold("window", true) } + this._onWindowFocus = (e) => { if (e.target === window) this._setHold("window", false) } this._onWindowToast = (e) => this._spawn(e.detail || {}) this._onWindowDismissAll = () => this._dismissById(null) + this._onToastChange = () => this._reflow() + this._onToastRemove = (e) => this._handOffFocus(e.target) + this._onRegionKeyDown = (e) => { + // Base UI: Tab from the focused viewport goes to the front toast. + if (e.key !== "Tab" || e.shiftKey || document.activeElement !== this.element) return + const front = this._frontToast() + if (!front) return + e.preventDefault() + front.focus() + } this._onKey = this._onKey.bind(this) window.addEventListener("ruby-ui:toast", this._onWindowToast) window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) - this._listEl.addEventListener("pointerenter", this._onPointerEnter) - this._listEl.addEventListener("pointerleave", this._onPointerLeave) + window.addEventListener("blur", this._onWindowBlur, true) + window.addEventListener("focus", this._onWindowFocus, true) + this.element.addEventListener("pointerenter", this._onPointerEnter) + this.element.addEventListener("pointerleave", this._onPointerLeave) + this.element.addEventListener("focusin", this._onFocusIn) + this.element.addEventListener("focusout", this._onFocusOut) + this.element.addEventListener("ruby-ui:toast:opened", this._onToastChange) + this.element.addEventListener("ruby-ui:toast:close", this._onToastChange) + this.element.addEventListener("ruby-ui:toast:remove", this._onToastRemove) + this.element.addEventListener("keydown", this._onRegionKeyDown) document.addEventListener("keydown", this._onKey) + if (this._hovered) this._setHold("hover", true) + if (this.element.contains(document.activeElement)) this._setHold("focus", true) + this._syncSwipeDirections() this._reflow() } disconnect() { window.removeEventListener("ruby-ui:toast", this._onWindowToast) window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) - this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) - this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) + window.removeEventListener("blur", this._onWindowBlur, true) + window.removeEventListener("focus", this._onWindowFocus, true) + this.element.removeEventListener("pointerenter", this._onPointerEnter) + this.element.removeEventListener("pointerleave", this._onPointerLeave) + this.element.removeEventListener("focusin", this._onFocusIn) + this.element.removeEventListener("focusout", this._onFocusOut) + this.element.removeEventListener("ruby-ui:toast:opened", this._onToastChange) + this.element.removeEventListener("ruby-ui:toast:close", this._onToastChange) + this.element.removeEventListener("ruby-ui:toast:remove", this._onToastRemove) + this.element.removeEventListener("keydown", this._onRegionKeyDown) document.removeEventListener("keydown", this._onKey) - this._listEl = null + this._holds.clear() + delete this.element.dataset.paused + this._announcers.forEach((node) => node.remove()) + this._announcers.clear() } positionValueChanged(value) { this.element.setAttribute("data-position", value) + this._syncSwipeDirections() this._reflow() } - expandValueChanged(value) { - this._expanded = value + expandValueChanged() { this._reflow() } + swipeDirectionsValueChanged() { + this._syncSwipeDirections() + } + toastTargetConnected(el) { this._resizeObservers.get(el)?.disconnect() if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { + // Only a toast at its own height tells us its natural height. + if (el.style.height !== "") return + if (this._heights.get(el) === el.offsetHeight) return this._heights.set(el, el.offsetHeight) this._reflow() }) ro.observe(el) this._resizeObservers.set(el, ro) } - this._heights.set(el, el.offsetHeight || 64) + this._heights.set(el, el.offsetHeight) + if (this._holds.size > 0) el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "region", on: true } })) + if (el.dataset.priority === "high") this._announce(el) + this._raiseAboveModals() this._reflow() } toastTargetDisconnected(el) { this._resizeObservers.get(el)?.disconnect() this._resizeObservers.delete(el) - this._heights.delete(el) + this._removeAnnouncer(el) this._reflow() } + // ---- top layer ------------------------------------------------------- + + _showPopover() { + if (typeof this.element.showPopover !== "function") return + try { + if (!this.element.matches(":popover-open")) this.element.showPopover() + } catch {} + } + + // A modal (RubyUI Dialog, AlertDialog, Sheet) sits in the top + // layer; showing the region again puts it above that dialog so new toasts + // are visible. (The page behind a modal stays inert, so they are not + // clickable until it closes.) + _raiseAboveModals() { + if (typeof this.element.showPopover !== "function") return + if (!document.querySelector("dialog:modal")) return + if (this.element.contains(document.activeElement)) return + try { + if (this.element.matches(":popover-open")) this.element.hidePopover() + this.element.showPopover() + } catch {} + } + + // ---- pausing --------------------------------------------------------- + + _setHold(reason, on) { + const wasHeld = this._holds.size > 0 + if (on) this._holds.add(reason) + else this._holds.delete(reason) + const held = this._holds.size > 0 + if (held) this.element.dataset.paused = [...this._holds].join(" ") + else delete this.element.dataset.paused + if (reason === "focus") this._syncHighPriorityVisibility(on) + if (held === wasHeld) return + this.toastTargets.forEach((el) => + el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "region", on: held } })) + ) + } + + // A focused toast leaving the DOM fires no focusout: move focus to the next + // front toast, else back to where it was before F6, and drop the focus hold. + _handOffFocus(toast) { + if (!toast.contains(document.activeElement)) return + const next = this._frontToast(toast) + if (next) return next.focus() + const previous = this._previousFocus + this._previousFocus = null + if (previous?.isConnected) previous.focus({ preventScroll: true }) + else document.activeElement?.blur() + this._setHold("focus", false) + this._reflow() + } + + _frontToast(except = null) { + return this.toastTargets + .filter((el) => el !== except && el.dataset.state !== "closing" && !el.hasAttribute("data-limited")) + .at(-1) + } + + // ---- high priority (Base UI): role=alertdialog, announced through a + // visually hidden role=alert; the toast itself is hidden from AT until + // focus enters the region, so it isn't read twice. + + _announce(el) { + if (this._holds.has("focus")) return + const title = el.querySelector('[data-slot="toast-title"]')?.textContent || "" + const description = el.querySelector('[data-slot="toast-description"]')?.textContent || "" + const node = document.createElement("div") + node.setAttribute("role", "alert") + node.setAttribute("aria-atomic", "true") + node.className = "sr-only" + node.dataset.toastAnnouncement = "" + node.textContent = [title, description].filter(Boolean).join(". ") + this.element.insertAdjacentElement("afterend", node) + this._announcers.get(el)?.remove() + this._announcers.set(el, node) + el.setAttribute("aria-hidden", "true") + } + + _removeAnnouncer(el) { + this._announcers.get(el)?.remove() + this._announcers.delete(el) + } + + // With focus inside, the toasts themselves are read: drop the hidden + // announcements (so nothing is read twice) and expose the toasts. + _syncHighPriorityVisibility(focused) { + if (focused) { + this._announcers.forEach((node) => node.remove()) + this._announcers.clear() + } + this.toastTargets + .filter((el) => el.dataset.priority === "high") + .forEach((el) => (focused ? el.removeAttribute("aria-hidden") : el.setAttribute("aria-hidden", "true"))) + } + + // ---- stacking -------------------------------------------------------- + + _isTop() { + return this.positionValue.startsWith("top") + } + + _syncSwipeDirections() { + let directions = this.swipeDirectionsValue + if (!directions) { + const position = this.positionValue + directions = [ + position.startsWith("top") ? "up" : "down", + position.endsWith("right") ? "right" : position.endsWith("left") ? "left" : null, + ].filter(Boolean).join(" ") + } + this.element.dataset.swipeDirections = directions + } + + _naturalHeight(el) { + return this._heights.get(el) || el.offsetHeight || 64 + } + + // Base UI's stack: newest in front; collapsed toasts peek 12px and shrink + // 10% per step, at the front toast's height with their content hidden; + // expanded toasts sit `gap` apart. Toasts past `max` stay mounted but hidden + // (data-limited) and come back when a front toast closes. + _reflow() { + if (!this._listEl) return + const top = this._isTop() + const sign = top ? 1 : -1 + const expanded = this.expandValue || this._hovered || this._holds.has("focus") + const order = this.toastTargets.filter((el) => el.dataset.state !== "closing").reverse() + const frontHeight = order.length ? this._naturalHeight(order[0]) : 0 + let offset = 0 + + order.forEach((el, i) => { + const limited = i >= this.maxValue + this._setLimited(el, limited) + el.style.zIndex = String(1000 - i) + el.style.transformOrigin = top ? "center top" : "center bottom" + el.style.top = top ? "0" : "auto" + el.style.bottom = top ? "auto" : "0" + + if (expanded) { + el.removeAttribute("data-behind") + el.style.height = "" + el.style.transform = el.dataset.state === "pending" ? this._startTransform() : `translateY(${sign * offset}px)` + } else { + const scale = Math.max(0, 1 - i * SCALE_STEP) + const lift = i * PEEK + (1 - scale) * frontHeight + if (i > 0) el.setAttribute("data-behind", "") + else el.removeAttribute("data-behind") + el.style.height = i > 0 ? `${frontHeight}px` : "" + el.style.transform = el.dataset.state === "pending" ? this._startTransform() : `translateY(${sign * lift}px) scale(${scale})` + } + if (el.dataset.state === "pending") void el.offsetWidth // paint the start position before it opens + + offset += this._naturalHeight(el) + this.gapValue + }) + this.element.dataset.expanded = String(expanded) + } + + _startTransform() { + return `translateY(${this._isTop() ? "-150%" : "150%"})` + } + + _setLimited(el, limited) { + if (limited === el.hasAttribute("data-limited")) return + if (limited) { + el.setAttribute("data-limited", "") + el.inert = true + } else { + el.removeAttribute("data-limited") + el.inert = false + } + el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "limited", on: limited } })) + } + + // ---- spawning -------------------------------------------------------- + _spawn(detail) { if (!this._listEl) return null const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" @@ -114,61 +350,63 @@ export default class extends Controller { if (!tpl) return null if (detail.position) this.positionValue = detail.position const node = tpl.content.firstElementChild.cloneNode(true) + const content = node.querySelector('[data-slot="toast-content"]') || node node.id = detail.id || `toast-${this._uuid()}` - if (detail.duration != null) { - const dur = detail.duration === Infinity ? 0 : detail.duration - node.setAttribute("data-ruby-ui--toast-duration-value", String(dur)) + const duration = detail.duration ?? this.durationValue + node.setAttribute("data-ruby-ui--toast-duration-value", String(duration === Infinity ? 0 : duration)) + if (detail.dismissible === false) node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") + if (detail.swipeDirections) { + const list = [].concat(detail.swipeDirections) + node.setAttribute("data-ruby-ui--toast-swipe-directions-value", list.length ? list.join(" ") : "none") } - if (detail.dismissible === false) { - node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") + if (detail.priority === "high") { + node.dataset.priority = "high" + node.setAttribute("role", "alertdialog") } if (detail.className) node.className += ` ${detail.className}` - const titleEl = node.querySelector('[data-slot="title"]') + const titleEl = node.querySelector('[data-slot="toast-title"]') if (titleEl) titleEl.textContent = detail.title || detail.message || "" - const descEl = node.querySelector('[data-slot="description"]') + const descEl = node.querySelector('[data-slot="toast-description"]') if (descEl) { if (detail.description) descEl.textContent = detail.description else descEl.remove() } - if (detail.action && detail.action.label && this.hasActionTplTarget) { - const btn = this._cloneSlot(this.actionTplTarget) - btn.textContent = detail.action.label - btn.addEventListener("click", (ev) => { - try { detail.action.onClick?.(ev) } finally { - node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - } - }) - node.appendChild(btn) + let close = node.querySelector('[data-slot="toast-close"]') + if (detail.closeButton === false && close) { + close.remove() + close = null + } else if (detail.closeButton === true && !close && this.hasCloseTplTarget) { + close = this._cloneSlot(this.closeTplTarget) + content.appendChild(close) } - if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) { + if (detail.cancel?.label && this.hasCancelTplTarget) { const btn = this._cloneSlot(this.cancelTplTarget) btn.textContent = detail.cancel.label - node.appendChild(btn) + if (detail.cancel.onClick) btn.addEventListener("click", (ev) => detail.cancel.onClick(ev)) + content.insertBefore(btn, close) } - if (detail.closeButton && this.hasCloseTplTarget) { - const x = this._cloneSlot(this.closeTplTarget) - node.classList.add("pr-10") - node.appendChild(x) + if (detail.action?.label && this.hasActionTplTarget) { + const btn = this._cloneSlot(this.actionTplTarget) + btn.textContent = detail.action.label + if (detail.action.onClick) btn.addEventListener("click", (ev) => detail.action.onClick(ev)) + content.insertBefore(btn, close) } + if (typeof detail.onClose === "function") node.addEventListener("ruby-ui:toast:close", (e) => detail.onClose(e.detail)) + if (typeof detail.onRemove === "function") node.addEventListener("ruby-ui:toast:remove", (e) => detail.onRemove(e.detail)) + this._listEl.appendChild(node) return node.id } _dismissById(id) { - if (!id) { - this.toastTargets.forEach((el) => - el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - ) - return - } - const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) - if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + const targets = id ? [this._listEl?.querySelector(`#${CSS.escape(id)}`)].filter(Boolean) : this.toastTargets + targets.forEach((el) => el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))) } _skeletonFor(variant) { @@ -179,95 +417,31 @@ export default class extends Controller { return tpl.content.firstElementChild.cloneNode(true) } - _setExpanded(value) { - if (this._expanded === value) return - this._expanded = value - document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume")) - this._reflow() - } - - _reflow() { - if (!this._listEl) return - const isBottom = this.positionValue.startsWith("bottom") - const items = this.toastTargets - const order = isBottom ? items.slice().reverse() : items.slice() - const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64) - const gap = this.gapValue - const peekOffset = 16 - const peekScaleStep = 0.05 - const peekOpacityStep = 0.2 - - const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1) - const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset - this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px` - - let acc = 0 - order.forEach((el, i) => { - const visible = i < this.maxValue - let yOffset, scale, opacity - - if (this._expanded) { - yOffset = acc + i * gap - scale = 1 - opacity = visible ? 1 : 0 - } else { - yOffset = i * peekOffset - scale = Math.max(0.85, 1 - i * peekScaleStep) - opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0 - } - - const sign = isBottom ? -1 : 1 - const ty = sign * yOffset - - el.style.setProperty("--opacity", String(opacity)) - el.style.setProperty("--scale", String(scale)) - el.style.setProperty("--y-offset", `${ty}px`) - el.style.transformOrigin = isBottom ? "center bottom" : "center top" - el.style.top = isBottom ? "auto" : "0" - el.style.bottom = isBottom ? "0" : "auto" - el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})` - el.style.zIndex = String(1000 - i) - el.style.pointerEvents = visible ? "auto" : "none" - el.tabIndex = visible ? 0 : -1 - - acc += heights[i] || 0 - }) - - this._enforceMax(items) - } - - _enforceMax(items) { - if (items.length <= this.maxValue) return - const isBottom = this.positionValue.startsWith("bottom") - const dropping = items.length - this.maxValue - const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping) - candidates.forEach(el => { - if (el.dataset.state !== "closing") { - el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) - } - }) - } - + // Base UI: the hotkey (F6) moves focus to the viewport; Tab then goes to + // the front toast. _onKey(e) { - const parts = (this.hotkeyValue || "alt+t").split("+") + if (!e.key) return + const parts = (this.hotkeyValue || "F6").toLowerCase().split("+") const key = parts.pop() - const wantAlt = parts.includes("alt") - const wantCtrl = parts.includes("ctrl") - const wantMeta = parts.includes("meta") - if (e.key.toLowerCase() !== key.toLowerCase()) return - if (wantAlt !== e.altKey) return - if (wantCtrl !== e.ctrlKey) return - if (wantMeta !== e.metaKey) return + if (e.key.toLowerCase() !== key) return + if (parts.includes("alt") !== e.altKey) return + if (parts.includes("ctrl") !== e.ctrlKey) return + if (parts.includes("meta") !== e.metaKey) return + if (parts.includes("shift") !== e.shiftKey) return + if (!this._frontToast() || document.activeElement === this.element) return e.preventDefault() - const first = this._listEl?.firstElementChild - first?.focus() + if (!this.element.contains(document.activeElement)) this._previousFocus = document.activeElement + this.element.focus({ preventScroll: true }) } _registerGlobalApi() { - const fire = (variant, message, opts = {}) => + const fire = (variant, message, opts = {}) => { + const id = opts.id || `toast-${this._uuid()}` window.dispatchEvent(new CustomEvent("ruby-ui:toast", { - detail: { ...opts, variant, message: opts.title || message } + detail: { ...opts, id, variant, message: opts.title || message } })) + return id + } const api = (message, opts) => fire("default", message, opts) api.success = (m, o) => fire("success", m, o) @@ -279,9 +453,9 @@ export default class extends Controller { if (id) this._dismissById(id) else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all")) } + api.close = api.dismiss api.promise = (p, msgs = {}) => { - const id = `toast-${this._uuid()}` - fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 }) + const id = fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { duration: 0 }) Promise.resolve(p).then( (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success), (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error) @@ -297,21 +471,20 @@ export default class extends Controller { const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) if (!el) return el.dataset.variant = variant - el.setAttribute("role", variant === "error" ? "alert" : "status") + el.dataset.type = variant this._swapIcon(el, variant) - const t = el.querySelector('[data-slot="title"]') + const t = el.querySelector('[data-slot="toast-title"]') if (t && text) t.textContent = text - const dur = String(this.durationValue) - el.setAttribute("data-ruby-ui--toast-duration-value", dur) + el.setAttribute("data-ruby-ui--toast-duration-value", String(this.durationValue)) el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true })) } _swapIcon(el, variant) { - const iconHost = el.querySelector('[data-slot="icon"]') + const iconHost = el.querySelector('[data-slot="toast-icon"]') if (!iconHost) return const tpl = this._skeletonFor(variant) if (!tpl) return - const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]') + const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="toast-icon"]') iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : "" } diff --git a/gem/test/ruby_ui/toast_item_test.rb b/gem/test/ruby_ui/toast_item_test.rb index c44ffc67b..814a8a597 100644 --- a/gem/test/ruby_ui/toast_item_test.rb +++ b/gem/test/ruby_ui/toast_item_test.rb @@ -3,19 +3,44 @@ require "test_helper" class RubyUI::ToastItemTest < ComponentTest - def test_renders_default_variant_with_status_role + def test_renders_base_ui_dialog_role out = phlex { RubyUI.ToastItem { "hi" } } - assert_match(/data-variant="default"/, out) - assert_match(/role="status"/, out) + assert_match(/]*role="dialog"/, out) + assert_match(/aria-modal="false"/, out) + assert_match(/data-slot="toast"/, out) + assert_match(/data-type="default"/, out) assert_match(/data-controller="ruby-ui--toast"/, out) assert_match(/data-ruby-ui--toaster-target="toast"/, out) assert_match(/data-state="pending"/, out) end - def test_error_variant_uses_alert_role + def test_no_live_region_role_per_toast out = phlex { RubyUI.ToastItem(variant: :error) { "x" } } - assert_match(/role="alert"/, out) - assert_match(/data-variant="error"/, out) + refute_match(/role="(status|alert)"/, out) + refute_match(/aria-atomic/, out) + assert_match(/role="dialog"/, out) + end + + def test_wraps_block_in_toast_content + out = phlex { RubyUI.ToastItem { "hi" } } + assert_match(/hi<\/div>/, out) + end + + def test_vega_card_classes + out = phlex { RubyUI.ToastItem { "x" } } + assert_match(/rounded-2xl/, out) + assert_match(/shadow-lg/, out) + refute_match(/text-\[13px\]/, out) + end + + def test_high_priority_is_alertdialog + out = phlex { RubyUI.ToastItem(priority: :high) { "x" } } + assert_match(/role="alertdialog"/, out) + assert_match(/data-priority="high"/, out) + end + + def test_unknown_priority_raises + assert_raises(ArgumentError) { RubyUI::ToastItem.new(priority: :urgent) } end def test_duration_propagated_as_data_value @@ -23,6 +48,12 @@ def test_duration_propagated_as_data_value assert_match(/data-ruby-ui--toast-duration-value="7000"/, out) end + # The toast controller then reads the region's duration (checked in the browser). + def test_duration_omitted_leaves_no_duration_value + out = phlex { RubyUI.ToastItem { "x" } } + refute_match(/data-ruby-ui--toast-duration-value/, out) + end + def test_dismissible_default_true out = phlex { RubyUI.ToastItem { "x" } } assert_match(/data-ruby-ui--toast-dismissible-value="true"/, out) @@ -32,4 +63,16 @@ def test_id_attribute out = phlex { RubyUI.ToastItem(id: "abc") { "x" } } assert_match(/id="abc"/, out) end + + def test_on_dismiss_and_on_remove_bind_toast_events + out = phlex { RubyUI.ToastItem(on_dismiss: "undo#commit", on_remove: "undo#cleanup") { "x" } } + assert_match(/data-action="ruby-ui:toast:close->undo#commit ruby-ui:toast:remove->undo#cleanup"|data-action="ruby-ui:toast:close->undo#commit ruby-ui:toast:remove->undo#cleanup"/, out) + end + + def test_removed_kwargs_raise + %i[invert on_auto_close].each do |kwarg| + error = assert_raises(ArgumentError) { RubyUI::ToastItem.new(kwarg => true) } + assert_match(/#{kwarg}:/, error.message) + end + end end diff --git a/gem/test/ruby_ui/toast_region_test.rb b/gem/test/ruby_ui/toast_region_test.rb index 9b0376a3a..bf31233c4 100644 --- a/gem/test/ruby_ui/toast_region_test.rb +++ b/gem/test/ruby_ui/toast_region_test.rb @@ -3,9 +3,9 @@ require "test_helper" class RubyUI::ToastRegionTest < ComponentTest - def test_renders_ol_with_target_id + def test_renders_toast_container_with_target_id out = phlex { RubyUI.ToastRegion() } - assert_match(/]*id="ruby-ui-toaster"/, out) + assert_match(/ 1) } + assert_match(/#{kwarg}:/, error.message) + end end def test_renders_flash_messages @@ -45,4 +98,13 @@ def test_renders_flash_messages assert_match(/Oops/, out) assert_match(/id="flash-notice"/, out) end + + private + + def skeletons(out) + variants = %w[default success error warning info loading] + found = variants.to_h { |v| [v, out[/.*?<\/template>/m]] } + assert_equal variants, found.compact.keys + found + end end diff --git a/gem/test/ruby_ui/toast_test.rb b/gem/test/ruby_ui/toast_test.rb index 71eb93f30..6c4203dde 100644 --- a/gem/test/ruby_ui/toast_test.rb +++ b/gem/test/ruby_ui/toast_test.rb @@ -18,8 +18,8 @@ def test_compose_region_with_item_and_slots assert_match(/Saved/, out) assert_match(/All good/, out) assert_match(/Undo/, out) - assert_match(/data-slot="close"/, out) - assert_match(/data-slot="icon"/, out) + assert_match(/data-slot="toast-close"/, out) + assert_match(/data-slot="toast-icon"/, out) end def test_flash_variant_helper @@ -28,18 +28,24 @@ def test_flash_variant_helper assert_equal :default, RubyUI::Toast.flash_variant(:unknown) end - def test_action_renders_label_and_action_attr + def test_action_is_an_outline_sm_button_that_closes out = phlex { RubyUI.ToastAction(label: "Undo", on: "click->thing#undo") } assert_match(/Undo/, out) - assert_match(/data-slot="action"/, out) - assert_match(/data-action="click->thing#undo"/, out) + assert_match(/data-slot="toast-action"/, out) + refute_match(/data-slot="button/, out) + assert_match(/data-action="click-(>|>)thing#undo click-(>|>)ruby-ui--toast#close"/, out) + assert_match(/border-border bg-background/, out) + assert_match(/h-8/, out) end - def test_cancel_renders_label_and_dismiss_action + def test_cancel_is_a_ghost_sm_button_that_dismisses out = phlex { RubyUI.ToastCancel(label: "Dismiss") } assert_match(/Dismiss/, out) - assert_match(/data-slot="cancel"/, out) - assert_match(/click->ruby-ui--toast#dismiss|click->ruby-ui--toast#dismiss/, out) + assert_match(/data-slot="toast-cancel"/, out) + assert_match(/click-(>|>)ruby-ui--toast#dismiss/, out) + assert_match(/h-8/, out) + assert_match(/hover:bg-muted/, out) + refute_match(/border-border bg-background/, out) end def test_icon_renders_per_variant @@ -55,24 +61,33 @@ def test_icon_renders_per_variant end end + def test_error_icon_is_destructive + out = phlex { RubyUI.ToastIcon(variant: :error) } + assert_match(/text-destructive/, out) + assert_match(/data-slot="toast-icon"/, out) + end + def test_icon_default_variant_renders_nothing out = phlex { RubyUI.ToastIcon(variant: :default) } refute_match(/ruby-ui--toast#dismiss/, out) - assert_match(/sr-only/, out) + assert_match(/data-slot="toast-close"/, out) + assert_match(/aria-label="Close toast"/, out) + assert_match(/click-(>|>)ruby-ui--toast#dismiss/, out) + assert_match(/h-8 w-8/, out) + assert_match(/text-muted-foreground/, out) + assert_match(/hover:bg-muted/, out) + refute_match(/border-border bg-background/, out) + refute_match(/absolute right-2/, out) end def test_title_and_description_slot_attrs title_out = phlex { RubyUI.ToastTitle { "Saved" } } desc_out = phlex { RubyUI.ToastDescription { "details" } } - assert_match(/data-slot="title"/, title_out) - assert_match(/Saved/, title_out) - assert_match(/data-slot="description"/, desc_out) - assert_match(/details/, desc_out) + assert_match(/Saved<\/h2>/, title_out) + assert_match(/details<\/p>/, desc_out) end end diff --git a/mcp/data/registry/components/toast.json b/mcp/data/registry/components/toast.json index 088b27fd6..9f71cd4a4 100644 --- a/mcp/data/registry/components/toast.json +++ b/mcp/data/registry/components/toast.json @@ -8,47 +8,49 @@ }, { "path": "toast_action.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastAction < Base\n def initialize(label:, on: nil, **attrs)\n @label = label\n @on = on\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n data = {slot: \"action\"}\n data[:action] = @on if @on\n {\n type: \"button\",\n data: data,\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Primary action: shadcn's ToastAction (Button outline, size sm). Clicking it\n # also closes the toast (registered divergence: Base UI leaves that to the\n # handler).\n class ToastAction < Base\n def initialize(label:, on: nil, **attrs)\n @label = label\n @on = on\n super(**attrs)\n end\n\n def view_template\n render RubyUI::Button.new(variant: :outline, size: :sm, **button_attrs) { @label }\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-action\"))\n end\n\n def default_attrs\n actions = [@on, \"click->ruby-ui--toast#close\"].compact\n {data: {action: actions.join(\" \")}, class: \"shrink-0\"}\n end\n end\nend\n" }, { "path": "toast_cancel.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastCancel < Base\n def initialize(label:, **attrs)\n @label = label\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n data: {\n slot: \"cancel\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Secondary dismiss button (a RubyUI extra; shadcn's Toast has no cancel).\n class ToastCancel < Base\n def initialize(label:, **attrs)\n @label = label\n super(**attrs)\n end\n\n def view_template\n render RubyUI::Button.new(variant: :ghost, size: :sm, **button_attrs) { @label }\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-cancel\"))\n end\n\n def default_attrs\n {data: {action: \"click->ruby-ui--toast#dismiss\"}, class: \"shrink-0\"}\n end\n end\nend\n" }, { "path": "toast_close.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastClose < Base\n def view_template\n button(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"14\",\n height: \"14\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-3.5\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n aria_label: \"Close toast\",\n data: {\n slot: \"close\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # shadcn's ToastClose: a ghost icon-sm Button in the toast row, with a\n # larger hit area (after:-inset-2).\n class ToastClose < Base\n def view_template\n render RubyUI::Button.new(variant: :ghost, size: :icon_sm, **button_attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n aria_hidden: \"true\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n end\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-close\"))\n end\n\n def default_attrs\n {\n aria_label: \"Close toast\",\n data: {action: \"click->ruby-ui--toast#dismiss\"},\n class: \"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground\"\n }\n end\n end\nend\n" }, { "path": "toast_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst SWIPE_THRESHOLD = 45\nconst TIME_BEFORE_UNMOUNT = 200\n\n// Connects to data-controller=\"ruby-ui--toast\"\nexport default class extends Controller {\n static values = {\n duration: { type: Number, default: 4000 },\n dismissible: { type: Boolean, default: true },\n invert: { type: Boolean, default: false },\n onDismiss: String,\n onAutoClose: String,\n }\n\n connect() {\n this._timer = null\n this._startedAt = 0\n this._remaining = this.durationValue\n this._paused = false\n this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }\n\n this._onPointerDown = this._onPointerDown.bind(this)\n this._onPointerMove = this._onPointerMove.bind(this)\n this._onPointerUp = this._onPointerUp.bind(this)\n this._onPointerEnter = () => this._pause()\n this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }\n this._onKeyDown = this._onKeyDown.bind(this)\n this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }\n this._onRestart = () => this._restart()\n this._onRegionPause = () => this._pause()\n this._onRegionResume = () => this._resume()\n\n this.element.addEventListener(\"pointerdown\", this._onPointerDown)\n this.element.addEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.addEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.addEventListener(\"keydown\", this._onKeyDown)\n this.element.addEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.addEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.addEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.addEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n\n requestAnimationFrame(() => {\n this.element.dataset.state = \"open\"\n this._start()\n })\n }\n\n disconnect() {\n this._clearTimer()\n this.element.removeEventListener(\"pointerdown\", this._onPointerDown)\n this.element.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.removeEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.removeEventListener(\"keydown\", this._onKeyDown)\n this.element.removeEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.removeEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.removeEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.removeEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n }\n\n dismiss(e) {\n e?.preventDefault()\n if (!this.dismissibleValue) return\n this._close(\"dismiss\")\n }\n\n _close(reason) {\n if (this.element.dataset.state === \"closing\") return\n this.element.dataset.state = \"closing\"\n this.element.dispatchEvent(new CustomEvent(reason === \"auto\" ? \"ruby-ui:toast:auto-close\" : \"ruby-ui:toast:dismiss\", { bubbles: true, detail: { id: this.element.id } }))\n setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)\n }\n\n _start() {\n if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return\n this._startedAt = performance.now()\n this._remaining = this.durationValue\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _restart() {\n this._clearTimer()\n this._start()\n }\n\n _pause() {\n if (this._paused || !this._timer) return\n this._paused = true\n clearTimeout(this._timer)\n this._timer = null\n this._remaining -= performance.now() - this._startedAt\n }\n\n _resume() {\n if (!this._paused) return\n this._paused = false\n if (this._remaining <= 0) return this._close(\"auto\")\n this._startedAt = performance.now()\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _clearTimer() {\n if (this._timer) clearTimeout(this._timer)\n this._timer = null\n }\n\n _onKeyDown(e) {\n if (e.key === \"Escape\" && this.dismissibleValue) this.dismiss(e)\n }\n\n _onPointerDown(e) {\n if (!this.dismissibleValue) return\n if (e.target.closest(\"button\")) return\n try { this.element.setPointerCapture(e.pointerId) } catch {}\n this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }\n this.element.dataset.swipe = \"start\"\n this.element.addEventListener(\"pointermove\", this._onPointerMove)\n this.element.addEventListener(\"pointerup\", this._onPointerUp)\n this.element.addEventListener(\"pointercancel\", this._onPointerUp)\n }\n\n _onPointerMove(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n this.element.dataset.swipe = \"move\"\n this.element.style.transform = `translate(${dx}px, ${dy}px)`\n }\n\n _onPointerUp(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n const dist = Math.hypot(dx, dy)\n const dt = performance.now() - this._swipe.startedAt\n const velocity = dist / Math.max(dt, 1)\n this.element.removeEventListener(\"pointermove\", this._onPointerMove)\n this.element.removeEventListener(\"pointerup\", this._onPointerUp)\n this.element.removeEventListener(\"pointercancel\", this._onPointerUp)\n this._swipe.active = false\n if (dist > SWIPE_THRESHOLD || velocity > 0.5) {\n this.element.style.setProperty(\"--swipe-end-x\", `${Math.sign(dx) * 500}px`)\n this.element.style.setProperty(\"--swipe-end-y\", `${Math.sign(dy) * 500}px`)\n this.element.dataset.swipe = \"end\"\n this.element.style.transform = \"\"\n this._close(\"dismiss\")\n } else {\n this.element.dataset.swipe = \"cancel\"\n this.element.style.transform = \"\"\n this._resume()\n }\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst SWIPE_THRESHOLD = 40\nconst DEFAULT_DURATION = 5000\nconst DIRECTIONS = [\"up\", \"down\", \"left\", \"right\"]\n\nlet uid = 0\n\n// Connects to data-controller=\"ruby-ui--toast\"\n//\n// One toast: its timer, swipe-to-dismiss and exit. The toaster controller\n// (region) owns stacking, limits and pausing; it talks to toasts through\n// ruby-ui:toast:* events dispatched on the toast element.\nexport default class extends Controller {\n static values = {\n duration: Number,\n dismissible: { type: Boolean, default: true },\n swipeDirections: String,\n }\n\n connect() {\n this._timer = null\n this._startedAt = 0\n this._remaining = 0\n this._holds = new Set()\n this._swipe = null\n\n this._wireLabels()\n\n this._onPointerDown = this._onPointerDown.bind(this)\n this._onPointerMove = this._onPointerMove.bind(this)\n this._onPointerUp = this._onPointerUp.bind(this)\n this._onForceDismiss = (e) => { e.stopPropagation(); this._close(\"dismiss\") }\n this._onRestart = () => this._restart()\n this._onHold = (e) => this._setHold(e.detail.reason, e.detail.on)\n this._onKeyDown = (e) => {\n if (e.key === \"Escape\" && this.element.contains(document.activeElement)) this.dismiss(e)\n }\n\n this.element.addEventListener(\"pointerdown\", this._onPointerDown)\n this.element.addEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.addEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n this.element.addEventListener(\"ruby-ui:toast:hold\", this._onHold)\n this.element.addEventListener(\"keydown\", this._onKeyDown)\n\n if (this.element.dataset.state === \"pending\") {\n // Two frames: the toaster paints the start position (off-screen) first,\n // so the move to the stack position transitions.\n this._enterFrame = requestAnimationFrame(() => {\n this._enterFrame = requestAnimationFrame(() => {\n this._enterFrame = null\n this.element.dataset.state = \"open\"\n this.element.dispatchEvent(new CustomEvent(\"ruby-ui:toast:opened\", { bubbles: true }))\n })\n })\n }\n this._start()\n }\n\n disconnect() {\n if (this._enterFrame) cancelAnimationFrame(this._enterFrame)\n this._endSwipe({ resume: false })\n this._clearTimer()\n this.element.removeEventListener(\"pointerdown\", this._onPointerDown)\n this.element.removeEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.removeEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n this.element.removeEventListener(\"ruby-ui:toast:hold\", this._onHold)\n this.element.removeEventListener(\"keydown\", this._onKeyDown)\n }\n\n // X, Cancel and swipe: respect dismissible.\n dismiss(e) {\n e?.preventDefault()\n if (!this.dismissibleValue) return\n this._close(\"dismiss\")\n }\n\n // The action button always closes the toast.\n close(e) {\n e?.preventDefault()\n this._close(\"dismiss\")\n }\n\n // aria-labelledby / aria-describedby, as Base UI's Toast.Root does. A\n // dialog needs a name: without a title, the description names it, else a\n // generic label. Labels the author set are left alone.\n _wireLabels() {\n const el = this.element\n const filled = (node) => node && node.textContent.trim() !== \"\"\n const title = el.querySelector('[data-slot=\"toast-title\"]')\n const description = el.querySelector('[data-slot=\"toast-description\"]')\n const base = el.id || `ruby-ui-toast-${++uid}`\n const named = el.hasAttribute(\"aria-labelledby\") || el.hasAttribute(\"aria-label\")\n if (filled(title)) title.id ||= `${base}-title`\n if (filled(description)) description.id ||= `${base}-description`\n if (!named) {\n if (filled(title)) el.setAttribute(\"aria-labelledby\", title.id)\n else if (filled(description)) el.setAttribute(\"aria-labelledby\", description.id)\n else el.setAttribute(\"aria-label\", \"Notification\")\n }\n const describedByTitle = filled(title) || named\n if (filled(description) && describedByTitle && !el.hasAttribute(\"aria-describedby\")) {\n el.setAttribute(\"aria-describedby\", description.id)\n }\n }\n\n _duration() {\n if (this.hasDurationValue) return this.durationValue\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n const inherited = Number(region?.getAttribute(\"data-ruby-ui--toaster-duration-value\"))\n return Number.isNaN(inherited) || region === null ? DEFAULT_DURATION : inherited\n }\n\n // 0, Infinity and non-numbers mean \"stays until dismissed\".\n _start() {\n this._clearTimer()\n const duration = this._duration()\n if (!Number.isFinite(duration) || duration <= 0) { this._remaining = Infinity; return }\n this._remaining = duration\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n if (region?.dataset.paused) this._holds.add(\"region\")\n if (this.element.hasAttribute(\"data-limited\")) this._holds.add(\"limited\")\n if (this._holds.size === 0) this._run()\n }\n\n _restart() {\n this._start()\n }\n\n _run() {\n if (!Number.isFinite(this._remaining)) return\n if (this._remaining <= 0) return this._close(\"auto\")\n this._startedAt = performance.now()\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _setHold(reason, on) {\n const wasHeld = this._holds.size > 0\n if (on) this._holds.add(reason)\n else this._holds.delete(reason)\n const held = this._holds.size > 0\n if (held && !wasHeld && this._timer) {\n this._clearTimer()\n this._remaining -= performance.now() - this._startedAt\n } else if (!held && wasHeld && this.element.dataset.state !== \"closing\") {\n this._run()\n }\n }\n\n _clearTimer() {\n if (this._timer) clearTimeout(this._timer)\n this._timer = null\n }\n\n _close(reason, exitTransform = null) {\n if (this.element.dataset.state === \"closing\") return\n const el = this.element\n const detail = { id: el.id, reason }\n el.dataset.state = \"closing\"\n this._clearTimer()\n this._endSwipe()\n el.removeAttribute(\"data-behind\")\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:close\", { bubbles: true, detail }))\n el.dispatchEvent(new CustomEvent(reason === \"auto\" ? \"ruby-ui:toast:auto-close\" : \"ruby-ui:toast:dismiss\", { bubbles: true, detail }))\n\n const top = el.closest(\"[data-position]\")?.dataset.position?.startsWith(\"top\")\n el.style.transform = exitTransform || `translateY(${top ? \"-150%\" : \"150%\"})`\n\n const transforming = () => el.getAnimations().some((a) => a.transitionProperty === \"transform\")\n let removed = false\n const remove = () => {\n if (removed) return\n removed = true\n el.removeEventListener(\"transitionend\", onEnd)\n el.removeEventListener(\"transitioncancel\", onEnd)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:remove\", { bubbles: true, detail }))\n el.remove()\n }\n // A cancelled enter transition also fires here; wait for the exit one.\n const onEnd = (e) => { if (e.target === el && e.propertyName === \"transform\" && !transforming()) remove() }\n el.addEventListener(\"transitionend\", onEnd)\n el.addEventListener(\"transitioncancel\", onEnd)\n // Flush styles so the exit transition starts now; with none running\n // (reduced motion, same transform) there is nothing to wait for.\n void el.offsetWidth\n if (document.hidden || !transforming()) remove()\n }\n\n _directions() {\n const own = this.swipeDirectionsValue\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n const list = own || region?.dataset.swipeDirections || \"down right\"\n if (list === \"none\") return []\n return list.split(/\\s+/).filter((d) => DIRECTIONS.includes(d))\n }\n\n _onPointerDown(e) {\n if (!this.dismissibleValue || e.button !== 0) return\n if (e.target.closest(\"button, a, input, select, textarea\")) return\n const directions = this._directions()\n if (directions.length === 0) return\n try { this.element.setPointerCapture(e.pointerId) } catch {}\n this._swipe = { x: e.clientX, y: e.clientY, startedAt: performance.now(), base: this.element.style.transform, directions, dx: 0, dy: 0 }\n this._setHold(\"swipe\", true)\n this.element.addEventListener(\"pointermove\", this._onPointerMove)\n this.element.addEventListener(\"pointerup\", this._onPointerUp)\n this.element.addEventListener(\"pointercancel\", this._onPointerUp)\n }\n\n // Free movement along allowed directions, resistance along the others.\n _onPointerMove(e) {\n const s = this._swipe\n if (!s) return\n const rawX = e.clientX - s.x\n const rawY = e.clientY - s.y\n const allowedX = (rawX > 0 && s.directions.includes(\"right\")) || (rawX < 0 && s.directions.includes(\"left\"))\n const allowedY = (rawY > 0 && s.directions.includes(\"down\")) || (rawY < 0 && s.directions.includes(\"up\"))\n s.dx = allowedX ? rawX : Math.sign(rawX) * Math.sqrt(Math.abs(rawX))\n s.dy = allowedY ? rawY : Math.sign(rawY) * Math.sqrt(Math.abs(rawY))\n this.element.dataset.swipe = \"move\"\n this.element.style.transform = `translateX(${s.dx}px) translateY(${s.dy}px) ${s.base}`\n }\n\n _onPointerUp() {\n const s = this._swipe\n if (!s) return\n const elapsed = Math.max(performance.now() - s.startedAt, 1)\n const exits = {\n right: { distance: s.dx, transform: `translateX(calc(${s.dx}px + 150%)) translateY(${s.dy}px) ${s.base}` },\n left: { distance: -s.dx, transform: `translateX(calc(${s.dx}px - 150%)) translateY(${s.dy}px) ${s.base}` },\n down: { distance: s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px + 150%)) ${s.base}` },\n up: { distance: -s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px - 150%)) ${s.base}` },\n }\n const direction = s.directions.find((d) => {\n const distance = exits[d].distance\n return distance > SWIPE_THRESHOLD || (distance > 10 && distance / elapsed > 0.5)\n })\n const base = s.base\n this._endSwipe()\n if (direction) {\n this.element.dataset.swipe = \"end\"\n this.element.dataset.swipeDirection = direction\n this._close(\"dismiss\", exits[direction].transform)\n } else {\n this.element.dataset.swipe = \"cancel\"\n this.element.style.transform = base\n }\n }\n\n _endSwipe({ resume = true } = {}) {\n if (!this._swipe) return\n this._swipe = null\n this.element.removeEventListener(\"pointermove\", this._onPointerMove)\n this.element.removeEventListener(\"pointerup\", this._onPointerUp)\n this.element.removeEventListener(\"pointercancel\", this._onPointerUp)\n if (resume) this._setHold(\"swipe\", false)\n else this._holds.delete(\"swipe\")\n }\n}\n" }, { "path": "toast_description.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastDescription < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"description\"},\n class: \"font-normal leading-[1.4] text-muted-foreground\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Secondary text; the toast is described by it (aria-describedby, wired by\n # the toast controller). Base UI renders a .\n class ToastDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {data: {slot: \"toast-description\"}, class: \"text-sm text-muted-foreground\"}\n end\n end\nend\n" }, { "path": "toast_icon.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastIcon < Base\n def initialize(variant: nil, **attrs)\n @variant = variant&.to_sym\n super(**attrs)\n end\n\n def view_template\n return unless renderable?\n span(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"#{svg_classes} -ml-px\"\n ) { |s| paths(s) }\n end\n end\n\n private\n\n def renderable?\n %i[success error warning info loading].include?(@variant)\n end\n\n def svg_classes\n base = \"size-4\"\n (@variant == :loading) ? \"#{base} animate-spin\" : base\n end\n\n def paths(s)\n case @variant\n when :success\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"m9 12 2 2 4-4\")\n when :error\n 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\")\n s.path(d: \"m15 9-6 6\")\n s.path(d: \"m9 9 6 6\")\n when :warning\n 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\")\n s.path(d: \"M12 9v4\")\n s.path(d: \"M12 17h.01\")\n when :info\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"M12 16v-4\")\n s.path(d: \"M12 8h.01\")\n when :loading\n s.path(d: \"M21 12a9 9 0 1 1-6.219-8.56\")\n end\n end\n\n def default_attrs\n {data: {slot: \"icon\"}, class: \"shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground\"}\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastIcon < Base\n def initialize(variant: nil, **attrs)\n @variant = variant&.to_sym\n super(**attrs)\n end\n\n def view_template\n return unless renderable?\n span(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: svg_classes,\n aria_hidden: \"true\"\n ) { |s| paths(s) }\n end\n end\n\n private\n\n def renderable?\n %i[success error warning info loading].include?(@variant)\n end\n\n def svg_classes\n case @variant\n when :loading then \"size-4 animate-spin\"\n when :error then \"size-4 text-destructive\"\n else \"size-4\"\n end\n end\n\n def paths(s)\n case @variant\n when :success\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"m9 12 2 2 4-4\")\n when :error\n 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\")\n s.path(d: \"m15 9-6 6\")\n s.path(d: \"m9 9 6 6\")\n when :warning\n 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\")\n s.path(d: \"M12 9v4\")\n s.path(d: \"M12 17h.01\")\n when :info\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"M12 16v-4\")\n s.path(d: \"M12 8h.01\")\n when :loading\n s.path(d: \"M21 12a9 9 0 1 1-6.219-8.56\")\n end\n end\n\n def default_attrs\n {data: {slot: \"toast-icon\"}, class: \"shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\"}\n end\n end\nend\n" }, { "path": "toast_item.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastItem < Base\n ALERT_VARIANTS = %i[error].freeze\n\n def initialize(\n variant: :default,\n id: nil,\n duration: nil,\n dismissible: true,\n invert: false,\n on_dismiss: nil,\n on_auto_close: nil,\n **attrs\n )\n @variant = variant.to_sym\n @id = id\n @duration = duration\n @dismissible = dismissible\n @invert = invert\n @on_dismiss = on_dismiss\n @on_auto_close = on_auto_close\n super(**attrs)\n end\n\n def view_template(&)\n li(**attrs, &)\n end\n\n private\n\n def default_attrs\n a = {\n role: ALERT_VARIANTS.include?(@variant) ? \"alert\" : \"status\",\n aria_atomic: \"true\",\n tabindex: \"0\",\n data: {\n variant: @variant.to_s,\n state: \"pending\",\n swipe: \"none\",\n controller: \"ruby-ui--toast\",\n ruby_ui__toaster_target: \"toast\",\n ruby_ui__toast_dismissible_value: @dismissible.to_s,\n ruby_ui__toast_invert_value: @invert.to_s\n },\n class: item_classes\n }\n a[:id] = @id if @id\n a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration\n a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss\n a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close\n a\n end\n\n def item_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toast pointer-events-auto absolute left-0 right-0\n flex w-[356px] max-w-full items-center gap-1.5\n rounded-lg border bg-popover text-popover-foreground\n border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)]\n group-data-[close-button=true]/toaster:pr-10\n transition-[transform,opacity] duration-300 ease-out\n will-change-transform\n opacity-[var(--opacity,1)]\n data-[state=pending]:opacity-0\n data-[state=closing]:opacity-0\n data-[swipe=move]:transition-none\n CLASSES\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # One toast. Mirrors shadcn's Base UI Toast (role=dialog, aria-modal=false,\n # named by its title and described by its description) and wraps the block\n # in Base UI's ToastContent row.\n #\n # on_dismiss / on_remove take Stimulus action descriptors (\"undo#commit\")\n # bound to the toast's own ruby-ui:toast:close / ruby-ui:toast:remove events,\n # so the named controller must be on the toast or an ancestor.\n class ToastItem < Base\n PRIORITIES = %i[low high].freeze\n REMOVED_KWARGS = %i[invert on_auto_close].freeze\n\n def initialize(\n variant: :default,\n id: nil,\n duration: nil,\n dismissible: true,\n priority: :low,\n on_dismiss: nil,\n on_remove: nil,\n **attrs\n )\n removed = attrs.keys & REMOVED_KWARGS\n unless removed.empty?\n raise ArgumentError, \"RubyUI::ToastItem: #{removed.map { |k| \"#{k}:\" }.join(\", \")} \" \\\n \"#{(removed.size == 1) ? \"was\" : \"were\"} removed (it never had an effect). See the Toast changelog.\"\n end\n\n @variant = variant.to_sym\n @id = id\n @duration = duration\n @dismissible = dismissible\n @priority = priority.to_sym\n raise ArgumentError, \"RubyUI::ToastItem: priority must be :low or :high\" unless PRIORITIES.include?(@priority)\n\n @on_dismiss = on_dismiss\n @on_remove = on_remove\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs) do\n div(data: {slot: \"toast-content\"}, class: content_classes, &)\n end\n end\n\n private\n\n def default_attrs\n actions = []\n actions << \"ruby-ui:toast:close->#{@on_dismiss}\" if @on_dismiss\n actions << \"ruby-ui:toast:remove->#{@on_remove}\" if @on_remove\n data = {\n slot: \"toast\",\n type: @variant.to_s,\n variant: @variant.to_s,\n priority: @priority.to_s,\n state: \"pending\",\n swipe: \"none\",\n controller: \"ruby-ui--toast\",\n ruby_ui__toaster_target: \"toast\",\n ruby_ui__toast_dismissible_value: @dismissible.to_s\n }\n data[:action] = actions.join(\" \") unless actions.empty?\n data[:ruby_ui__toast_duration_value] = @duration.to_s if @duration\n a = {\n role: (@priority == :high) ? \"alertdialog\" : \"dialog\",\n aria_modal: \"false\",\n tabindex: \"0\",\n data: data,\n class: item_classes\n }\n a[:id] = @id if @id\n a\n end\n\n # Base UI + vega toast root. The stacking transforms, heights and z-index\n # come from the toaster controller (inline styles), not CSS variables.\n def item_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toast pointer-events-auto absolute inset-x-0 w-full\n rounded-2xl border bg-popover text-popover-foreground shadow-lg\n will-change-transform outline-none select-none\n focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\n transition-[transform,opacity,height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]\n motion-reduce:transition-none\n data-[swipe=move]:transition-none\n data-[state=pending]:[transform:translateY(150%)]\n in-data-[position^=top]:data-[state=pending]:[transform:translateY(-150%)]\n data-[limited]:opacity-0 data-[limited]:pointer-events-none\n after:absolute after:top-full after:left-0 after:h-[13px] after:w-full after:content-['']\n before:absolute before:bottom-full before:left-0 before:h-[13px] before:w-full before:content-['']\n CLASSES\n end\n\n def content_classes\n \"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 \" \\\n \"ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[behind]/toast:opacity-0\"\n end\n end\nend\n" }, { "path": "toast_region.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastRegion < Base\n SKELETON_VARIANTS = %i[default success error warning info loading].freeze\n\n def initialize(\n position: :bottom_right,\n expand: false,\n max: 3,\n duration: 4000,\n gap: 14,\n offset: 24,\n theme: :system,\n rich_colors: false,\n close_button: false,\n hotkey: %w[alt t],\n dir: :ltr,\n flash: nil,\n **attrs\n )\n @position = position.to_sym\n @expand = expand\n @max = max\n @duration = duration\n @gap = gap\n @offset = offset\n @theme = theme.to_sym\n @rich_colors = rich_colors\n @close_button = close_button\n @hotkey = hotkey\n @dir = dir\n @flash = flash\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n ol(id: \"ruby-ui-toaster\", class: \"pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full\") do\n render_flash if @flash\n yield(self) if block\n end\n SKELETON_VARIANTS.each { |v| skeleton(v) }\n slot_template(\"actionTpl\") { render RubyUI::ToastAction.new(label: \"\") }\n slot_template(\"cancelTpl\") { render RubyUI::ToastCancel.new(label: \"\") }\n slot_template(\"closeTpl\") { render RubyUI::ToastClose.new }\n end\n end\n\n private\n\n def render_flash\n @flash.each do |key, message|\n next if message.nil? || message.to_s.empty?\n variant = RubyUI::Toast.flash_variant(key)\n render RubyUI::ToastItem.new(variant: variant, id: \"flash-#{key}\") do\n render RubyUI::ToastIcon.new(variant: variant)\n render RubyUI::ToastTitle.new { message.to_s }\n end\n end\n end\n\n def skeleton(variant)\n template(\n data: {\n ruby_ui__toaster_target: \"skeleton\",\n variant: variant.to_s\n }\n ) do\n render RubyUI::ToastItem.new(variant: variant) do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex flex-col gap-0.5 flex-1 min-w-0\") do\n render RubyUI::ToastTitle.new\n render RubyUI::ToastDescription.new\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def slot_template(target_name, &)\n template(data: {ruby_ui__toaster_target: target_name}, &)\n end\n\n def default_attrs\n {\n id: \"ruby-ui-toaster-region\",\n role: \"region\",\n aria_label: \"Notifications\",\n aria_live: \"polite\",\n data: {\n controller: \"ruby-ui--toaster\",\n turbo_permanent: \"\",\n close_button: @close_button.to_s,\n position: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_position_value: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_expand_value: @expand.to_s,\n ruby_ui__toaster_max_value: @max.to_s,\n ruby_ui__toaster_duration_value: @duration.to_s,\n ruby_ui__toaster_gap_value: @gap.to_s,\n ruby_ui__toaster_offset_value: @offset.to_s,\n ruby_ui__toaster_theme_value: @theme.to_s,\n ruby_ui__toaster_rich_colors_value: @rich_colors.to_s,\n ruby_ui__toaster_close_button_value: @close_button.to_s,\n ruby_ui__toaster_hotkey_value: Array(@hotkey).join(\"+\"),\n ruby_ui__toaster_dir_value: @dir.to_s\n },\n class: region_classes\n }\n end\n\n def region_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6\n data-[position=top-left]:top-0 data-[position=top-left]:left-0\n data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2\n data-[position=top-right]:top-0 data-[position=top-right]:right-0\n data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0\n data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2\n data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0\n CLASSES\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # The toast viewport: a fixed, labelled live region that hosts the toasts.\n # Mount it once per page (layout). Mirrors shadcn's Base UI Toaster\n # (toast-viewport); positions other than bottom-right are a RubyUI extra.\n #\n # The region is a popover=\"manual\" so each new toast can lift it into the\n # top layer, above a RubyUI Dialog/Sheet opened with showModal().\n class ToastRegion < Base\n SKELETON_VARIANTS = %i[default success error warning info loading].freeze\n POSITIONS = %i[top_left top_center top_right bottom_left bottom_center bottom_right].freeze\n SWIPE_DIRECTIONS = %i[up down left right].freeze\n REMOVED_KWARGS = %i[offset theme rich_colors dir].freeze\n\n def initialize(\n position: :bottom_right,\n expand: false,\n max: 3,\n duration: 5000,\n gap: 12,\n close_button: true,\n hotkey: %w[F6],\n swipe_directions: nil,\n flash: nil,\n **attrs\n )\n removed = attrs.keys & REMOVED_KWARGS\n unless removed.empty?\n raise ArgumentError, \"RubyUI::ToastRegion: #{removed.map { |k| \"#{k}:\" }.join(\", \")} \" \\\n \"#{(removed.size == 1) ? \"was\" : \"were\"} removed (it never had an effect). See the Toast changelog.\"\n end\n\n @position = position.to_sym\n raise ArgumentError, \"RubyUI::ToastRegion: unknown position #{position.inspect}\" unless POSITIONS.include?(@position)\n\n @expand = expand\n @max = max\n @duration = duration\n @gap = gap\n @close_button = close_button\n @hotkey = hotkey\n @swipe_directions = swipe_directions && Array(swipe_directions).map(&:to_sym)\n unknown = Array(@swipe_directions) - SWIPE_DIRECTIONS\n raise ArgumentError, \"RubyUI::ToastRegion: unknown swipe direction(s) #{unknown.inspect}\" unless unknown.empty?\n @flash = flash\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n div(id: \"ruby-ui-toaster\", class: \"relative\") do\n render_flash if @flash\n yield(self) if block\n end\n SKELETON_VARIANTS.each { |v| skeleton(v) }\n slot_template(\"actionTpl\") { render RubyUI::ToastAction.new(label: \"\") }\n slot_template(\"cancelTpl\") { render RubyUI::ToastCancel.new(label: \"\") }\n slot_template(\"closeTpl\") { render RubyUI::ToastClose.new }\n end\n end\n\n private\n\n def render_flash\n @flash.each do |key, message|\n next if message.nil? || message.to_s.empty?\n variant = RubyUI::Toast.flash_variant(key)\n render RubyUI::ToastItem.new(variant: variant, id: \"flash-#{key}\") do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex min-w-0 flex-1 flex-col gap-1\") do\n render RubyUI::ToastTitle.new { message.to_s }\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def skeleton(variant)\n template(data: {ruby_ui__toaster_target: \"skeleton\", variant: variant.to_s}) do\n render RubyUI::ToastItem.new(variant: variant) do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex min-w-0 flex-1 flex-col gap-1\") do\n render RubyUI::ToastTitle.new\n render RubyUI::ToastDescription.new\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def slot_template(target_name, &)\n template(data: {ruby_ui__toaster_target: target_name}, &)\n end\n\n def default_attrs\n position = @position.to_s.tr(\"_\", \"-\")\n data = {\n slot: \"toast-viewport\",\n controller: \"ruby-ui--toaster\",\n turbo_permanent: \"\",\n position: position,\n ruby_ui__toaster_position_value: position,\n ruby_ui__toaster_expand_value: @expand.to_s,\n ruby_ui__toaster_max_value: @max.to_s,\n ruby_ui__toaster_duration_value: @duration.to_s,\n ruby_ui__toaster_gap_value: @gap.to_s,\n ruby_ui__toaster_close_button_value: @close_button.to_s,\n ruby_ui__toaster_hotkey_value: Array(@hotkey).join(\"+\")\n }\n # \"none\" disables swipe; leaving it unset derives it from the position.\n if @swipe_directions\n data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.empty? ? \"none\" : @swipe_directions.join(\" \")\n end\n {\n id: \"ruby-ui-toaster-region\",\n role: \"region\",\n aria_label: \"Notifications\",\n aria_live: \"polite\",\n aria_relevant: \"additions text\",\n aria_atomic: \"false\",\n tabindex: \"-1\",\n popover: \"manual\",\n data: data,\n class: region_classes\n }\n end\n\n # Base UI's toast-viewport classes (bottom-right), plus resets for the\n # popover UA styles (inset: 0, margin: auto, border, padding, background)\n # and the other five positions.\n def region_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n pointer-events-none fixed z-50 m-0 h-auto w-auto max-w-sm overflow-visible\n border-0 bg-transparent p-0 text-inherit outline-none\n inset-x-4 mx-auto sm:mx-0 sm:w-full\n data-[position^=bottom]:top-auto data-[position^=bottom]:bottom-4\n data-[position^=top]:top-4 data-[position^=top]:bottom-auto\n data-[position$=right]:sm:right-4 data-[position$=right]:sm:left-auto\n data-[position$=left]:sm:left-4 data-[position$=left]:sm:right-auto\n data-[position$=center]:sm:left-1/2 data-[position$=center]:sm:right-auto data-[position$=center]:sm:-translate-x-1/2\n CLASSES\n end\n end\nend\n" }, { "path": "toast_title.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastTitle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"title\"},\n class: \"font-medium leading-normal\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # The toast's heading; the toast is named by it (aria-labelledby, wired by\n # the toast controller). Base UI renders an .\n class ToastTitle < Base\n def view_template(&)\n h2(**attrs, &)\n end\n\n private\n\n def default_attrs\n {data: {slot: \"toast-title\"}, class: \"text-sm font-medium\"}\n end\n end\nend\n" }, { "path": "toaster_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n positionValueChanged(value) {\n this.element.setAttribute(\"data-position\", value)\n this._reflow()\n }\n\n expandValueChanged(value) {\n this._expanded = value\n this._reflow()\n }\n\n toastTargetConnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) this.positionValue = detail.position\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\nconst PEEK = 12\nconst SCALE_STEP = 0.1\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\n//\n// The toast viewport (shadcn's Base UI Toaster): stacks the toasts, limits how\n// many show, pauses every timer while the region is hovered or focused (or the\n// window is blurred), focuses the region on the hotkey (F6), and spawns\n// toasts from window.RubyUI.toast / the ruby-ui:toast event / Turbo Streams.\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 5000 },\n gap: { type: Number, default: 12 },\n closeButton: { type: Boolean, default: true },\n hotkey: { type: String, default: \"F6\" },\n swipeDirections: String,\n }\n\n initialize() {\n this._hovered = false\n this._holds = new Set()\n this._heights = new WeakMap()\n this._resizeObservers = new WeakMap()\n this._announcers = new Map() // toast element -> its hidden role=alert\n }\n\n connect() {\n // Turbo moves this permanent region between pages: start from a clean\n // pause state instead of whatever the last page left behind.\n this._hovered = this.element.matches(\":hover\")\n this._holds.clear()\n delete this.element.dataset.paused\n this._listEl = this.element.querySelector(\"#ruby-ui-toaster\") || this.element\n this._registerGlobalApi()\n registerStreamAction()\n this._showPopover()\n\n this._onPointerEnter = () => { this._hovered = true; this._setHold(\"hover\", true); this._reflow() }\n this._onPointerLeave = () => { this._hovered = false; this._setHold(\"hover\", false); this._reflow() }\n this._onFocusIn = () => { this._setHold(\"focus\", true); this._reflow() }\n this._onFocusOut = (e) => {\n if (this.element.contains(e.relatedTarget)) return\n this._setHold(\"focus\", false)\n this._reflow()\n }\n this._onWindowBlur = (e) => { if (e.target === window) this._setHold(\"window\", true) }\n this._onWindowFocus = (e) => { if (e.target === window) this._setHold(\"window\", false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onToastChange = () => this._reflow()\n this._onToastRemove = (e) => this._handOffFocus(e.target)\n this._onRegionKeyDown = (e) => {\n // Base UI: Tab from the focused viewport goes to the front toast.\n if (e.key !== \"Tab\" || e.shiftKey || document.activeElement !== this.element) return\n const front = this._frontToast()\n if (!front) return\n e.preventDefault()\n front.focus()\n }\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n window.addEventListener(\"blur\", this._onWindowBlur, true)\n window.addEventListener(\"focus\", this._onWindowFocus, true)\n this.element.addEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.addEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.addEventListener(\"focusin\", this._onFocusIn)\n this.element.addEventListener(\"focusout\", this._onFocusOut)\n this.element.addEventListener(\"ruby-ui:toast:opened\", this._onToastChange)\n this.element.addEventListener(\"ruby-ui:toast:close\", this._onToastChange)\n this.element.addEventListener(\"ruby-ui:toast:remove\", this._onToastRemove)\n this.element.addEventListener(\"keydown\", this._onRegionKeyDown)\n document.addEventListener(\"keydown\", this._onKey)\n\n if (this._hovered) this._setHold(\"hover\", true)\n if (this.element.contains(document.activeElement)) this._setHold(\"focus\", true)\n this._syncSwipeDirections()\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n window.removeEventListener(\"blur\", this._onWindowBlur, true)\n window.removeEventListener(\"focus\", this._onWindowFocus, true)\n this.element.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.removeEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.removeEventListener(\"focusin\", this._onFocusIn)\n this.element.removeEventListener(\"focusout\", this._onFocusOut)\n this.element.removeEventListener(\"ruby-ui:toast:opened\", this._onToastChange)\n this.element.removeEventListener(\"ruby-ui:toast:close\", this._onToastChange)\n this.element.removeEventListener(\"ruby-ui:toast:remove\", this._onToastRemove)\n this.element.removeEventListener(\"keydown\", this._onRegionKeyDown)\n document.removeEventListener(\"keydown\", this._onKey)\n this._holds.clear()\n delete this.element.dataset.paused\n this._announcers.forEach((node) => node.remove())\n this._announcers.clear()\n }\n\n positionValueChanged(value) {\n this.element.setAttribute(\"data-position\", value)\n this._syncSwipeDirections()\n this._reflow()\n }\n\n expandValueChanged() {\n this._reflow()\n }\n\n swipeDirectionsValueChanged() {\n this._syncSwipeDirections()\n }\n\n toastTargetConnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n // Only a toast at its own height tells us its natural height.\n if (el.style.height !== \"\") return\n if (this._heights.get(el) === el.offsetHeight) return\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight)\n if (this._holds.size > 0) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:hold\", { detail: { reason: \"region\", on: true } }))\n if (el.dataset.priority === \"high\") this._announce(el)\n this._raiseAboveModals()\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._removeAnnouncer(el)\n this._reflow()\n }\n\n // ---- top layer -------------------------------------------------------\n\n _showPopover() {\n if (typeof this.element.showPopover !== \"function\") return\n try {\n if (!this.element.matches(\":popover-open\")) this.element.showPopover()\n } catch {}\n }\n\n // A modal (RubyUI Dialog, AlertDialog, Sheet) sits in the top\n // layer; showing the region again puts it above that dialog so new toasts\n // are visible. (The page behind a modal stays inert, so they are not\n // clickable until it closes.)\n _raiseAboveModals() {\n if (typeof this.element.showPopover !== \"function\") return\n if (!document.querySelector(\"dialog:modal\")) return\n if (this.element.contains(document.activeElement)) return\n try {\n if (this.element.matches(\":popover-open\")) this.element.hidePopover()\n this.element.showPopover()\n } catch {}\n }\n\n // ---- pausing ---------------------------------------------------------\n\n _setHold(reason, on) {\n const wasHeld = this._holds.size > 0\n if (on) this._holds.add(reason)\n else this._holds.delete(reason)\n const held = this._holds.size > 0\n if (held) this.element.dataset.paused = [...this._holds].join(\" \")\n else delete this.element.dataset.paused\n if (reason === \"focus\") this._syncHighPriorityVisibility(on)\n if (held === wasHeld) return\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:hold\", { detail: { reason: \"region\", on: held } }))\n )\n }\n\n // A focused toast leaving the DOM fires no focusout: move focus to the next\n // front toast, else back to where it was before F6, and drop the focus hold.\n _handOffFocus(toast) {\n if (!toast.contains(document.activeElement)) return\n const next = this._frontToast(toast)\n if (next) return next.focus()\n const previous = this._previousFocus\n this._previousFocus = null\n if (previous?.isConnected) previous.focus({ preventScroll: true })\n else document.activeElement?.blur()\n this._setHold(\"focus\", false)\n this._reflow()\n }\n\n _frontToast(except = null) {\n return this.toastTargets\n .filter((el) => el !== except && el.dataset.state !== \"closing\" && !el.hasAttribute(\"data-limited\"))\n .at(-1)\n }\n\n // ---- high priority (Base UI): role=alertdialog, announced through a\n // visually hidden role=alert; the toast itself is hidden from AT until\n // focus enters the region, so it isn't read twice.\n\n _announce(el) {\n if (this._holds.has(\"focus\")) return\n const title = el.querySelector('[data-slot=\"toast-title\"]')?.textContent || \"\"\n const description = el.querySelector('[data-slot=\"toast-description\"]')?.textContent || \"\"\n const node = document.createElement(\"div\")\n node.setAttribute(\"role\", \"alert\")\n node.setAttribute(\"aria-atomic\", \"true\")\n node.className = \"sr-only\"\n node.dataset.toastAnnouncement = \"\"\n node.textContent = [title, description].filter(Boolean).join(\". \")\n this.element.insertAdjacentElement(\"afterend\", node)\n this._announcers.get(el)?.remove()\n this._announcers.set(el, node)\n el.setAttribute(\"aria-hidden\", \"true\")\n }\n\n _removeAnnouncer(el) {\n this._announcers.get(el)?.remove()\n this._announcers.delete(el)\n }\n\n // With focus inside, the toasts themselves are read: drop the hidden\n // announcements (so nothing is read twice) and expose the toasts.\n _syncHighPriorityVisibility(focused) {\n if (focused) {\n this._announcers.forEach((node) => node.remove())\n this._announcers.clear()\n }\n this.toastTargets\n .filter((el) => el.dataset.priority === \"high\")\n .forEach((el) => (focused ? el.removeAttribute(\"aria-hidden\") : el.setAttribute(\"aria-hidden\", \"true\")))\n }\n\n // ---- stacking --------------------------------------------------------\n\n _isTop() {\n return this.positionValue.startsWith(\"top\")\n }\n\n _syncSwipeDirections() {\n let directions = this.swipeDirectionsValue\n if (!directions) {\n const position = this.positionValue\n directions = [\n position.startsWith(\"top\") ? \"up\" : \"down\",\n position.endsWith(\"right\") ? \"right\" : position.endsWith(\"left\") ? \"left\" : null,\n ].filter(Boolean).join(\" \")\n }\n this.element.dataset.swipeDirections = directions\n }\n\n _naturalHeight(el) {\n return this._heights.get(el) || el.offsetHeight || 64\n }\n\n // Base UI's stack: newest in front; collapsed toasts peek 12px and shrink\n // 10% per step, at the front toast's height with their content hidden;\n // expanded toasts sit `gap` apart. Toasts past `max` stay mounted but hidden\n // (data-limited) and come back when a front toast closes.\n _reflow() {\n if (!this._listEl) return\n const top = this._isTop()\n const sign = top ? 1 : -1\n const expanded = this.expandValue || this._hovered || this._holds.has(\"focus\")\n const order = this.toastTargets.filter((el) => el.dataset.state !== \"closing\").reverse()\n const frontHeight = order.length ? this._naturalHeight(order[0]) : 0\n let offset = 0\n\n order.forEach((el, i) => {\n const limited = i >= this.maxValue\n this._setLimited(el, limited)\n el.style.zIndex = String(1000 - i)\n el.style.transformOrigin = top ? \"center top\" : \"center bottom\"\n el.style.top = top ? \"0\" : \"auto\"\n el.style.bottom = top ? \"auto\" : \"0\"\n\n if (expanded) {\n el.removeAttribute(\"data-behind\")\n el.style.height = \"\"\n el.style.transform = el.dataset.state === \"pending\" ? this._startTransform() : `translateY(${sign * offset}px)`\n } else {\n const scale = Math.max(0, 1 - i * SCALE_STEP)\n const lift = i * PEEK + (1 - scale) * frontHeight\n if (i > 0) el.setAttribute(\"data-behind\", \"\")\n else el.removeAttribute(\"data-behind\")\n el.style.height = i > 0 ? `${frontHeight}px` : \"\"\n el.style.transform = el.dataset.state === \"pending\" ? this._startTransform() : `translateY(${sign * lift}px) scale(${scale})`\n }\n if (el.dataset.state === \"pending\") void el.offsetWidth // paint the start position before it opens\n\n offset += this._naturalHeight(el) + this.gapValue\n })\n this.element.dataset.expanded = String(expanded)\n }\n\n _startTransform() {\n return `translateY(${this._isTop() ? \"-150%\" : \"150%\"})`\n }\n\n _setLimited(el, limited) {\n if (limited === el.hasAttribute(\"data-limited\")) return\n if (limited) {\n el.setAttribute(\"data-limited\", \"\")\n el.inert = true\n } else {\n el.removeAttribute(\"data-limited\")\n el.inert = false\n }\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:hold\", { detail: { reason: \"limited\", on: limited } }))\n }\n\n // ---- spawning --------------------------------------------------------\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) this.positionValue = detail.position\n const node = tpl.content.firstElementChild.cloneNode(true)\n const content = node.querySelector('[data-slot=\"toast-content\"]') || node\n\n node.id = detail.id || `toast-${this._uuid()}`\n const duration = detail.duration ?? this.durationValue\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(duration === Infinity ? 0 : duration))\n if (detail.dismissible === false) node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n if (detail.swipeDirections) {\n const list = [].concat(detail.swipeDirections)\n node.setAttribute(\"data-ruby-ui--toast-swipe-directions-value\", list.length ? list.join(\" \") : \"none\")\n }\n if (detail.priority === \"high\") {\n node.dataset.priority = \"high\"\n node.setAttribute(\"role\", \"alertdialog\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"toast-title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"toast-description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n let close = node.querySelector('[data-slot=\"toast-close\"]')\n if (detail.closeButton === false && close) {\n close.remove()\n close = null\n } else if (detail.closeButton === true && !close && this.hasCloseTplTarget) {\n close = this._cloneSlot(this.closeTplTarget)\n content.appendChild(close)\n }\n\n if (detail.cancel?.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n if (detail.cancel.onClick) btn.addEventListener(\"click\", (ev) => detail.cancel.onClick(ev))\n content.insertBefore(btn, close)\n }\n\n if (detail.action?.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n if (detail.action.onClick) btn.addEventListener(\"click\", (ev) => detail.action.onClick(ev))\n content.insertBefore(btn, close)\n }\n\n if (typeof detail.onClose === \"function\") node.addEventListener(\"ruby-ui:toast:close\", (e) => detail.onClose(e.detail))\n if (typeof detail.onRemove === \"function\") node.addEventListener(\"ruby-ui:toast:remove\", (e) => detail.onRemove(e.detail))\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n const targets = id ? [this._listEl?.querySelector(`#${CSS.escape(id)}`)].filter(Boolean) : this.toastTargets\n targets.forEach((el) => el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true })))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n // Base UI: the hotkey (F6) moves focus to the viewport; Tab then goes to\n // the front toast.\n _onKey(e) {\n if (!e.key) return\n const parts = (this.hotkeyValue || \"F6\").toLowerCase().split(\"+\")\n const key = parts.pop()\n if (e.key.toLowerCase() !== key) return\n if (parts.includes(\"alt\") !== e.altKey) return\n if (parts.includes(\"ctrl\") !== e.ctrlKey) return\n if (parts.includes(\"meta\") !== e.metaKey) return\n if (parts.includes(\"shift\") !== e.shiftKey) return\n if (!this._frontToast() || document.activeElement === this.element) return\n e.preventDefault()\n if (!this.element.contains(document.activeElement)) this._previousFocus = document.activeElement\n this.element.focus({ preventScroll: true })\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) => {\n const id = opts.id || `toast-${this._uuid()}`\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, id, variant, message: opts.title || message }\n }))\n return id\n }\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.close = api.dismiss\n api.promise = (p, msgs = {}) => {\n const id = fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.dataset.type = variant\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"toast-title\"]')\n if (t && text) t.textContent = text\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", String(this.durationValue))\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"toast-icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"toast-icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" } ], "dependencies": { - "components": [], + "components": [ + "Button" + ], "js_packages": [], "gems": [] },
. class ToastDescription < Base def view_template(&) - div(**attrs, &) + p(**attrs, &) end private def default_attrs - { - data: {slot: "description"}, - class: "font-normal leading-[1.4] text-muted-foreground" - } + {data: {slot: "toast-description"}, class: "text-sm text-muted-foreground"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_icon.rb b/gem/lib/ruby_ui/toast/toast_icon.rb index 9d0ee9a4a..bd34306b5 100644 --- a/gem/lib/ruby_ui/toast/toast_icon.rb +++ b/gem/lib/ruby_ui/toast/toast_icon.rb @@ -20,7 +20,8 @@ def view_template stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", - class: "#{svg_classes} -ml-px" + class: svg_classes, + aria_hidden: "true" ) { |s| paths(s) } end end @@ -32,8 +33,11 @@ def renderable? end def svg_classes - base = "size-4" - (@variant == :loading) ? "#{base} animate-spin" : base + case @variant + when :loading then "size-4 animate-spin" + when :error then "size-4 text-destructive" + else "size-4" + end end def paths(s) @@ -59,7 +63,7 @@ def paths(s) end def default_attrs - {data: {slot: "icon"}, class: "shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground"} + {data: {slot: "toast-icon"}, class: "shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"} end end end diff --git a/gem/lib/ruby_ui/toast/toast_item.rb b/gem/lib/ruby_ui/toast/toast_item.rb index e2052d42c..d0fa008b5 100644 --- a/gem/lib/ruby_ui/toast/toast_item.rb +++ b/gem/lib/ruby_ui/toast/toast_item.rb @@ -1,72 +1,103 @@ # frozen_string_literal: true module RubyUI + # One toast. Mirrors shadcn's Base UI Toast (role=dialog, aria-modal=false, + # named by its title and described by its description) and wraps the block + # in Base UI's ToastContent row. + # + # on_dismiss / on_remove take Stimulus action descriptors ("undo#commit") + # bound to the toast's own ruby-ui:toast:close / ruby-ui:toast:remove events, + # so the named controller must be on the toast or an ancestor. class ToastItem < Base - ALERT_VARIANTS = %i[error].freeze + PRIORITIES = %i[low high].freeze + REMOVED_KWARGS = %i[invert on_auto_close].freeze def initialize( variant: :default, id: nil, duration: nil, dismissible: true, - invert: false, + priority: :low, on_dismiss: nil, - on_auto_close: nil, + on_remove: nil, **attrs ) + removed = attrs.keys & REMOVED_KWARGS + unless removed.empty? + raise ArgumentError, "RubyUI::ToastItem: #{removed.map { |k| "#{k}:" }.join(", ")} " \ + "#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog." + end + @variant = variant.to_sym @id = id @duration = duration @dismissible = dismissible - @invert = invert + @priority = priority.to_sym + raise ArgumentError, "RubyUI::ToastItem: priority must be :low or :high" unless PRIORITIES.include?(@priority) + @on_dismiss = on_dismiss - @on_auto_close = on_auto_close + @on_remove = on_remove super(**attrs) end def view_template(&) - li(**attrs, &) + div(**attrs) do + div(data: {slot: "toast-content"}, class: content_classes, &) + end end private def default_attrs + actions = [] + actions << "ruby-ui:toast:close->#{@on_dismiss}" if @on_dismiss + actions << "ruby-ui:toast:remove->#{@on_remove}" if @on_remove + data = { + slot: "toast", + type: @variant.to_s, + variant: @variant.to_s, + priority: @priority.to_s, + state: "pending", + swipe: "none", + controller: "ruby-ui--toast", + ruby_ui__toaster_target: "toast", + ruby_ui__toast_dismissible_value: @dismissible.to_s + } + data[:action] = actions.join(" ") unless actions.empty? + data[:ruby_ui__toast_duration_value] = @duration.to_s if @duration a = { - role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status", - aria_atomic: "true", + role: (@priority == :high) ? "alertdialog" : "dialog", + aria_modal: "false", tabindex: "0", - data: { - variant: @variant.to_s, - state: "pending", - swipe: "none", - controller: "ruby-ui--toast", - ruby_ui__toaster_target: "toast", - ruby_ui__toast_dismissible_value: @dismissible.to_s, - ruby_ui__toast_invert_value: @invert.to_s - }, + data: data, class: item_classes } a[:id] = @id if @id - a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration - a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss - a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close a end + # Base UI + vega toast root. The stacking transforms, heights and z-index + # come from the toaster controller (inline styles), not CSS variables. def item_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip - group/toast pointer-events-auto absolute left-0 right-0 - flex w-[356px] max-w-full items-center gap-1.5 - rounded-lg border bg-popover text-popover-foreground - border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)] - group-data-[close-button=true]/toaster:pr-10 - transition-[transform,opacity] duration-300 ease-out - will-change-transform - opacity-[var(--opacity,1)] - data-[state=pending]:opacity-0 - data-[state=closing]:opacity-0 + group/toast pointer-events-auto absolute inset-x-0 w-full + rounded-2xl border bg-popover text-popover-foreground shadow-lg + will-change-transform outline-none select-none + focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 + transition-[transform,opacity,height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] + motion-reduce:transition-none data-[swipe=move]:transition-none + data-[state=pending]:[transform:translateY(150%)] + in-data-[position^=top]:data-[state=pending]:[transform:translateY(-150%)] + data-[limited]:opacity-0 data-[limited]:pointer-events-none + after:absolute after:top-full after:left-0 after:h-[13px] after:w-full after:content-[''] + before:absolute before:bottom-full before:left-0 before:h-[13px] before:w-full before:content-[''] CLASSES end + + def content_classes + "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 " \ + "ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[behind]/toast:opacity-0" + end end end diff --git a/gem/lib/ruby_ui/toast/toast_region.rb b/gem/lib/ruby_ui/toast/toast_region.rb index c8cc6e901..427726f82 100644 --- a/gem/lib/ruby_ui/toast/toast_region.rb +++ b/gem/lib/ruby_ui/toast/toast_region.rb @@ -1,42 +1,55 @@ # frozen_string_literal: true module RubyUI + # The toast viewport: a fixed, labelled live region that hosts the toasts. + # Mount it once per page (layout). Mirrors shadcn's Base UI Toaster + # (toast-viewport); positions other than bottom-right are a RubyUI extra. + # + # The region is a popover="manual" so each new toast can lift it into the + # top layer, above a RubyUI Dialog/Sheet opened with showModal(). class ToastRegion < Base SKELETON_VARIANTS = %i[default success error warning info loading].freeze + POSITIONS = %i[top_left top_center top_right bottom_left bottom_center bottom_right].freeze + SWIPE_DIRECTIONS = %i[up down left right].freeze + REMOVED_KWARGS = %i[offset theme rich_colors dir].freeze def initialize( position: :bottom_right, expand: false, max: 3, - duration: 4000, - gap: 14, - offset: 24, - theme: :system, - rich_colors: false, - close_button: false, - hotkey: %w[alt t], - dir: :ltr, + duration: 5000, + gap: 12, + close_button: true, + hotkey: %w[F6], + swipe_directions: nil, flash: nil, **attrs ) + removed = attrs.keys & REMOVED_KWARGS + unless removed.empty? + raise ArgumentError, "RubyUI::ToastRegion: #{removed.map { |k| "#{k}:" }.join(", ")} " \ + "#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog." + end + @position = position.to_sym + raise ArgumentError, "RubyUI::ToastRegion: unknown position #{position.inspect}" unless POSITIONS.include?(@position) + @expand = expand @max = max @duration = duration @gap = gap - @offset = offset - @theme = theme.to_sym - @rich_colors = rich_colors @close_button = close_button @hotkey = hotkey - @dir = dir + @swipe_directions = swipe_directions && Array(swipe_directions).map(&:to_sym) + unknown = Array(@swipe_directions) - SWIPE_DIRECTIONS + raise ArgumentError, "RubyUI::ToastRegion: unknown swipe direction(s) #{unknown.inspect}" unless unknown.empty? @flash = flash super(**attrs) end def view_template(&block) div(**attrs) do - ol(id: "ruby-ui-toaster", class: "pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full") do + div(id: "ruby-ui-toaster", class: "relative") do render_flash if @flash yield(self) if block end @@ -55,21 +68,19 @@ def render_flash variant = RubyUI::Toast.flash_variant(key) render RubyUI::ToastItem.new(variant: variant, id: "flash-#{key}") do render RubyUI::ToastIcon.new(variant: variant) - render RubyUI::ToastTitle.new { message.to_s } + div(class: "flex min-w-0 flex-1 flex-col gap-1") do + render RubyUI::ToastTitle.new { message.to_s } + end + render RubyUI::ToastClose.new if @close_button end end end def skeleton(variant) - template( - data: { - ruby_ui__toaster_target: "skeleton", - variant: variant.to_s - } - ) do + template(data: {ruby_ui__toaster_target: "skeleton", variant: variant.to_s}) do render RubyUI::ToastItem.new(variant: variant) do render RubyUI::ToastIcon.new(variant: variant) - div(class: "flex flex-col gap-0.5 flex-1 min-w-0") do + div(class: "flex min-w-0 flex-1 flex-col gap-1") do render RubyUI::ToastTitle.new render RubyUI::ToastDescription.new end @@ -83,41 +94,51 @@ def slot_template(target_name, &) end def default_attrs + position = @position.to_s.tr("_", "-") + data = { + slot: "toast-viewport", + controller: "ruby-ui--toaster", + turbo_permanent: "", + position: position, + ruby_ui__toaster_position_value: position, + ruby_ui__toaster_expand_value: @expand.to_s, + ruby_ui__toaster_max_value: @max.to_s, + ruby_ui__toaster_duration_value: @duration.to_s, + ruby_ui__toaster_gap_value: @gap.to_s, + ruby_ui__toaster_close_button_value: @close_button.to_s, + ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+") + } + # "none" disables swipe; leaving it unset derives it from the position. + if @swipe_directions + data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.empty? ? "none" : @swipe_directions.join(" ") + end { id: "ruby-ui-toaster-region", role: "region", aria_label: "Notifications", aria_live: "polite", - data: { - controller: "ruby-ui--toaster", - turbo_permanent: "", - close_button: @close_button.to_s, - position: @position.to_s.tr("_", "-"), - ruby_ui__toaster_position_value: @position.to_s.tr("_", "-"), - ruby_ui__toaster_expand_value: @expand.to_s, - ruby_ui__toaster_max_value: @max.to_s, - ruby_ui__toaster_duration_value: @duration.to_s, - ruby_ui__toaster_gap_value: @gap.to_s, - ruby_ui__toaster_offset_value: @offset.to_s, - ruby_ui__toaster_theme_value: @theme.to_s, - ruby_ui__toaster_rich_colors_value: @rich_colors.to_s, - ruby_ui__toaster_close_button_value: @close_button.to_s, - ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+"), - ruby_ui__toaster_dir_value: @dir.to_s - }, + aria_relevant: "additions text", + aria_atomic: "false", + tabindex: "-1", + popover: "manual", + data: data, class: region_classes } end + # Base UI's toast-viewport classes (bottom-right), plus resets for the + # popover UA styles (inset: 0, margin: auto, border, padding, background) + # and the other five positions. def region_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip - group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6 - data-[position=top-left]:top-0 data-[position=top-left]:left-0 - data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2 - data-[position=top-right]:top-0 data-[position=top-right]:right-0 - data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0 - data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2 - data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0 + pointer-events-none fixed z-50 m-0 h-auto w-auto max-w-sm overflow-visible + border-0 bg-transparent p-0 text-inherit outline-none + inset-x-4 mx-auto sm:mx-0 sm:w-full + data-[position^=bottom]:top-auto data-[position^=bottom]:bottom-4 + data-[position^=top]:top-4 data-[position^=top]:bottom-auto + data-[position$=right]:sm:right-4 data-[position$=right]:sm:left-auto + data-[position$=left]:sm:left-4 data-[position$=left]:sm:right-auto + data-[position$=center]:sm:left-1/2 data-[position$=center]:sm:right-auto data-[position$=center]:sm:-translate-x-1/2 CLASSES end end diff --git a/gem/lib/ruby_ui/toast/toast_title.rb b/gem/lib/ruby_ui/toast/toast_title.rb index b48dbfbe1..93b54ae7d 100644 --- a/gem/lib/ruby_ui/toast/toast_title.rb +++ b/gem/lib/ruby_ui/toast/toast_title.rb @@ -1,18 +1,17 @@ # frozen_string_literal: true module RubyUI + # The toast's heading; the toast is named by it (aria-labelledby, wired by + # the toast controller). Base UI renders an
details<\/p>/, desc_out) end end diff --git a/mcp/data/registry/components/toast.json b/mcp/data/registry/components/toast.json index 088b27fd6..9f71cd4a4 100644 --- a/mcp/data/registry/components/toast.json +++ b/mcp/data/registry/components/toast.json @@ -8,47 +8,49 @@ }, { "path": "toast_action.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastAction < Base\n def initialize(label:, on: nil, **attrs)\n @label = label\n @on = on\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n data = {slot: \"action\"}\n data[:action] = @on if @on\n {\n type: \"button\",\n data: data,\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Primary action: shadcn's ToastAction (Button outline, size sm). Clicking it\n # also closes the toast (registered divergence: Base UI leaves that to the\n # handler).\n class ToastAction < Base\n def initialize(label:, on: nil, **attrs)\n @label = label\n @on = on\n super(**attrs)\n end\n\n def view_template\n render RubyUI::Button.new(variant: :outline, size: :sm, **button_attrs) { @label }\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-action\"))\n end\n\n def default_attrs\n actions = [@on, \"click->ruby-ui--toast#close\"].compact\n {data: {action: actions.join(\" \")}, class: \"shrink-0\"}\n end\n end\nend\n" }, { "path": "toast_cancel.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastCancel < Base\n def initialize(label:, **attrs)\n @label = label\n super(**attrs)\n end\n\n def view_template\n button(**attrs) { @label }\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n data: {\n slot: \"cancel\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Secondary dismiss button (a RubyUI extra; shadcn's Toast has no cancel).\n class ToastCancel < Base\n def initialize(label:, **attrs)\n @label = label\n super(**attrs)\n end\n\n def view_template\n render RubyUI::Button.new(variant: :ghost, size: :sm, **button_attrs) { @label }\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-cancel\"))\n end\n\n def default_attrs\n {data: {action: \"click->ruby-ui--toast#dismiss\"}, class: \"shrink-0\"}\n end\n end\nend\n" }, { "path": "toast_close.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastClose < Base\n def view_template\n button(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"14\",\n height: \"14\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"size-3.5\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n span(class: \"sr-only\") { \"Close\" }\n end\n end\n\n private\n\n def default_attrs\n {\n type: \"button\",\n aria_label: \"Close toast\",\n data: {\n slot: \"close\",\n action: \"click->ruby-ui--toast#dismiss\"\n },\n class: \"absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # shadcn's ToastClose: a ghost icon-sm Button in the toast row, with a\n # larger hit area (after:-inset-2).\n class ToastClose < Base\n def view_template\n render RubyUI::Button.new(variant: :ghost, size: :icon_sm, **button_attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"24\",\n height: \"24\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n aria_hidden: \"true\"\n ) do |s|\n s.path(d: \"M18 6 6 18\")\n s.path(d: \"m6 6 12 12\")\n end\n end\n end\n\n private\n\n # slot! makes Button replace its own data-slot=\"button\" instead of joining\n # it. Added after Base's mix, which would strip the bang.\n def button_attrs\n attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: \"toast-close\"))\n end\n\n def default_attrs\n {\n aria_label: \"Close toast\",\n data: {action: \"click->ruby-ui--toast#dismiss\"},\n class: \"relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground\"\n }\n end\n end\nend\n" }, { "path": "toast_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst SWIPE_THRESHOLD = 45\nconst TIME_BEFORE_UNMOUNT = 200\n\n// Connects to data-controller=\"ruby-ui--toast\"\nexport default class extends Controller {\n static values = {\n duration: { type: Number, default: 4000 },\n dismissible: { type: Boolean, default: true },\n invert: { type: Boolean, default: false },\n onDismiss: String,\n onAutoClose: String,\n }\n\n connect() {\n this._timer = null\n this._startedAt = 0\n this._remaining = this.durationValue\n this._paused = false\n this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }\n\n this._onPointerDown = this._onPointerDown.bind(this)\n this._onPointerMove = this._onPointerMove.bind(this)\n this._onPointerUp = this._onPointerUp.bind(this)\n this._onPointerEnter = () => this._pause()\n this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }\n this._onKeyDown = this._onKeyDown.bind(this)\n this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }\n this._onRestart = () => this._restart()\n this._onRegionPause = () => this._pause()\n this._onRegionResume = () => this._resume()\n\n this.element.addEventListener(\"pointerdown\", this._onPointerDown)\n this.element.addEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.addEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.addEventListener(\"keydown\", this._onKeyDown)\n this.element.addEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.addEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.addEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.addEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n\n requestAnimationFrame(() => {\n this.element.dataset.state = \"open\"\n this._start()\n })\n }\n\n disconnect() {\n this._clearTimer()\n this.element.removeEventListener(\"pointerdown\", this._onPointerDown)\n this.element.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this.element.removeEventListener(\"pointerleave\", this._onPointerLeave)\n this.element.removeEventListener(\"keydown\", this._onKeyDown)\n this.element.removeEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.removeEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n document.removeEventListener(\"ruby-ui:toast:pause\", this._onRegionPause)\n document.removeEventListener(\"ruby-ui:toast:resume\", this._onRegionResume)\n }\n\n dismiss(e) {\n e?.preventDefault()\n if (!this.dismissibleValue) return\n this._close(\"dismiss\")\n }\n\n _close(reason) {\n if (this.element.dataset.state === \"closing\") return\n this.element.dataset.state = \"closing\"\n this.element.dispatchEvent(new CustomEvent(reason === \"auto\" ? \"ruby-ui:toast:auto-close\" : \"ruby-ui:toast:dismiss\", { bubbles: true, detail: { id: this.element.id } }))\n setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)\n }\n\n _start() {\n if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return\n this._startedAt = performance.now()\n this._remaining = this.durationValue\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _restart() {\n this._clearTimer()\n this._start()\n }\n\n _pause() {\n if (this._paused || !this._timer) return\n this._paused = true\n clearTimeout(this._timer)\n this._timer = null\n this._remaining -= performance.now() - this._startedAt\n }\n\n _resume() {\n if (!this._paused) return\n this._paused = false\n if (this._remaining <= 0) return this._close(\"auto\")\n this._startedAt = performance.now()\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _clearTimer() {\n if (this._timer) clearTimeout(this._timer)\n this._timer = null\n }\n\n _onKeyDown(e) {\n if (e.key === \"Escape\" && this.dismissibleValue) this.dismiss(e)\n }\n\n _onPointerDown(e) {\n if (!this.dismissibleValue) return\n if (e.target.closest(\"button\")) return\n try { this.element.setPointerCapture(e.pointerId) } catch {}\n this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }\n this.element.dataset.swipe = \"start\"\n this.element.addEventListener(\"pointermove\", this._onPointerMove)\n this.element.addEventListener(\"pointerup\", this._onPointerUp)\n this.element.addEventListener(\"pointercancel\", this._onPointerUp)\n }\n\n _onPointerMove(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n this.element.dataset.swipe = \"move\"\n this.element.style.transform = `translate(${dx}px, ${dy}px)`\n }\n\n _onPointerUp(e) {\n const dx = e.clientX - this._swipe.x\n const dy = e.clientY - this._swipe.y\n const dist = Math.hypot(dx, dy)\n const dt = performance.now() - this._swipe.startedAt\n const velocity = dist / Math.max(dt, 1)\n this.element.removeEventListener(\"pointermove\", this._onPointerMove)\n this.element.removeEventListener(\"pointerup\", this._onPointerUp)\n this.element.removeEventListener(\"pointercancel\", this._onPointerUp)\n this._swipe.active = false\n if (dist > SWIPE_THRESHOLD || velocity > 0.5) {\n this.element.style.setProperty(\"--swipe-end-x\", `${Math.sign(dx) * 500}px`)\n this.element.style.setProperty(\"--swipe-end-y\", `${Math.sign(dy) * 500}px`)\n this.element.dataset.swipe = \"end\"\n this.element.style.transform = \"\"\n this._close(\"dismiss\")\n } else {\n this.element.dataset.swipe = \"cancel\"\n this.element.style.transform = \"\"\n this._resume()\n }\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst SWIPE_THRESHOLD = 40\nconst DEFAULT_DURATION = 5000\nconst DIRECTIONS = [\"up\", \"down\", \"left\", \"right\"]\n\nlet uid = 0\n\n// Connects to data-controller=\"ruby-ui--toast\"\n//\n// One toast: its timer, swipe-to-dismiss and exit. The toaster controller\n// (region) owns stacking, limits and pausing; it talks to toasts through\n// ruby-ui:toast:* events dispatched on the toast element.\nexport default class extends Controller {\n static values = {\n duration: Number,\n dismissible: { type: Boolean, default: true },\n swipeDirections: String,\n }\n\n connect() {\n this._timer = null\n this._startedAt = 0\n this._remaining = 0\n this._holds = new Set()\n this._swipe = null\n\n this._wireLabels()\n\n this._onPointerDown = this._onPointerDown.bind(this)\n this._onPointerMove = this._onPointerMove.bind(this)\n this._onPointerUp = this._onPointerUp.bind(this)\n this._onForceDismiss = (e) => { e.stopPropagation(); this._close(\"dismiss\") }\n this._onRestart = () => this._restart()\n this._onHold = (e) => this._setHold(e.detail.reason, e.detail.on)\n this._onKeyDown = (e) => {\n if (e.key === \"Escape\" && this.element.contains(document.activeElement)) this.dismiss(e)\n }\n\n this.element.addEventListener(\"pointerdown\", this._onPointerDown)\n this.element.addEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.addEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n this.element.addEventListener(\"ruby-ui:toast:hold\", this._onHold)\n this.element.addEventListener(\"keydown\", this._onKeyDown)\n\n if (this.element.dataset.state === \"pending\") {\n // Two frames: the toaster paints the start position (off-screen) first,\n // so the move to the stack position transitions.\n this._enterFrame = requestAnimationFrame(() => {\n this._enterFrame = requestAnimationFrame(() => {\n this._enterFrame = null\n this.element.dataset.state = \"open\"\n this.element.dispatchEvent(new CustomEvent(\"ruby-ui:toast:opened\", { bubbles: true }))\n })\n })\n }\n this._start()\n }\n\n disconnect() {\n if (this._enterFrame) cancelAnimationFrame(this._enterFrame)\n this._endSwipe({ resume: false })\n this._clearTimer()\n this.element.removeEventListener(\"pointerdown\", this._onPointerDown)\n this.element.removeEventListener(\"ruby-ui:toast:force-dismiss\", this._onForceDismiss)\n this.element.removeEventListener(\"ruby-ui:toast:restart\", this._onRestart)\n this.element.removeEventListener(\"ruby-ui:toast:hold\", this._onHold)\n this.element.removeEventListener(\"keydown\", this._onKeyDown)\n }\n\n // X, Cancel and swipe: respect dismissible.\n dismiss(e) {\n e?.preventDefault()\n if (!this.dismissibleValue) return\n this._close(\"dismiss\")\n }\n\n // The action button always closes the toast.\n close(e) {\n e?.preventDefault()\n this._close(\"dismiss\")\n }\n\n // aria-labelledby / aria-describedby, as Base UI's Toast.Root does. A\n // dialog needs a name: without a title, the description names it, else a\n // generic label. Labels the author set are left alone.\n _wireLabels() {\n const el = this.element\n const filled = (node) => node && node.textContent.trim() !== \"\"\n const title = el.querySelector('[data-slot=\"toast-title\"]')\n const description = el.querySelector('[data-slot=\"toast-description\"]')\n const base = el.id || `ruby-ui-toast-${++uid}`\n const named = el.hasAttribute(\"aria-labelledby\") || el.hasAttribute(\"aria-label\")\n if (filled(title)) title.id ||= `${base}-title`\n if (filled(description)) description.id ||= `${base}-description`\n if (!named) {\n if (filled(title)) el.setAttribute(\"aria-labelledby\", title.id)\n else if (filled(description)) el.setAttribute(\"aria-labelledby\", description.id)\n else el.setAttribute(\"aria-label\", \"Notification\")\n }\n const describedByTitle = filled(title) || named\n if (filled(description) && describedByTitle && !el.hasAttribute(\"aria-describedby\")) {\n el.setAttribute(\"aria-describedby\", description.id)\n }\n }\n\n _duration() {\n if (this.hasDurationValue) return this.durationValue\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n const inherited = Number(region?.getAttribute(\"data-ruby-ui--toaster-duration-value\"))\n return Number.isNaN(inherited) || region === null ? DEFAULT_DURATION : inherited\n }\n\n // 0, Infinity and non-numbers mean \"stays until dismissed\".\n _start() {\n this._clearTimer()\n const duration = this._duration()\n if (!Number.isFinite(duration) || duration <= 0) { this._remaining = Infinity; return }\n this._remaining = duration\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n if (region?.dataset.paused) this._holds.add(\"region\")\n if (this.element.hasAttribute(\"data-limited\")) this._holds.add(\"limited\")\n if (this._holds.size === 0) this._run()\n }\n\n _restart() {\n this._start()\n }\n\n _run() {\n if (!Number.isFinite(this._remaining)) return\n if (this._remaining <= 0) return this._close(\"auto\")\n this._startedAt = performance.now()\n this._timer = setTimeout(() => this._close(\"auto\"), this._remaining)\n }\n\n _setHold(reason, on) {\n const wasHeld = this._holds.size > 0\n if (on) this._holds.add(reason)\n else this._holds.delete(reason)\n const held = this._holds.size > 0\n if (held && !wasHeld && this._timer) {\n this._clearTimer()\n this._remaining -= performance.now() - this._startedAt\n } else if (!held && wasHeld && this.element.dataset.state !== \"closing\") {\n this._run()\n }\n }\n\n _clearTimer() {\n if (this._timer) clearTimeout(this._timer)\n this._timer = null\n }\n\n _close(reason, exitTransform = null) {\n if (this.element.dataset.state === \"closing\") return\n const el = this.element\n const detail = { id: el.id, reason }\n el.dataset.state = \"closing\"\n this._clearTimer()\n this._endSwipe()\n el.removeAttribute(\"data-behind\")\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:close\", { bubbles: true, detail }))\n el.dispatchEvent(new CustomEvent(reason === \"auto\" ? \"ruby-ui:toast:auto-close\" : \"ruby-ui:toast:dismiss\", { bubbles: true, detail }))\n\n const top = el.closest(\"[data-position]\")?.dataset.position?.startsWith(\"top\")\n el.style.transform = exitTransform || `translateY(${top ? \"-150%\" : \"150%\"})`\n\n const transforming = () => el.getAnimations().some((a) => a.transitionProperty === \"transform\")\n let removed = false\n const remove = () => {\n if (removed) return\n removed = true\n el.removeEventListener(\"transitionend\", onEnd)\n el.removeEventListener(\"transitioncancel\", onEnd)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:remove\", { bubbles: true, detail }))\n el.remove()\n }\n // A cancelled enter transition also fires here; wait for the exit one.\n const onEnd = (e) => { if (e.target === el && e.propertyName === \"transform\" && !transforming()) remove() }\n el.addEventListener(\"transitionend\", onEnd)\n el.addEventListener(\"transitioncancel\", onEnd)\n // Flush styles so the exit transition starts now; with none running\n // (reduced motion, same transform) there is nothing to wait for.\n void el.offsetWidth\n if (document.hidden || !transforming()) remove()\n }\n\n _directions() {\n const own = this.swipeDirectionsValue\n const region = this.element.closest('[data-controller~=\"ruby-ui--toaster\"]')\n const list = own || region?.dataset.swipeDirections || \"down right\"\n if (list === \"none\") return []\n return list.split(/\\s+/).filter((d) => DIRECTIONS.includes(d))\n }\n\n _onPointerDown(e) {\n if (!this.dismissibleValue || e.button !== 0) return\n if (e.target.closest(\"button, a, input, select, textarea\")) return\n const directions = this._directions()\n if (directions.length === 0) return\n try { this.element.setPointerCapture(e.pointerId) } catch {}\n this._swipe = { x: e.clientX, y: e.clientY, startedAt: performance.now(), base: this.element.style.transform, directions, dx: 0, dy: 0 }\n this._setHold(\"swipe\", true)\n this.element.addEventListener(\"pointermove\", this._onPointerMove)\n this.element.addEventListener(\"pointerup\", this._onPointerUp)\n this.element.addEventListener(\"pointercancel\", this._onPointerUp)\n }\n\n // Free movement along allowed directions, resistance along the others.\n _onPointerMove(e) {\n const s = this._swipe\n if (!s) return\n const rawX = e.clientX - s.x\n const rawY = e.clientY - s.y\n const allowedX = (rawX > 0 && s.directions.includes(\"right\")) || (rawX < 0 && s.directions.includes(\"left\"))\n const allowedY = (rawY > 0 && s.directions.includes(\"down\")) || (rawY < 0 && s.directions.includes(\"up\"))\n s.dx = allowedX ? rawX : Math.sign(rawX) * Math.sqrt(Math.abs(rawX))\n s.dy = allowedY ? rawY : Math.sign(rawY) * Math.sqrt(Math.abs(rawY))\n this.element.dataset.swipe = \"move\"\n this.element.style.transform = `translateX(${s.dx}px) translateY(${s.dy}px) ${s.base}`\n }\n\n _onPointerUp() {\n const s = this._swipe\n if (!s) return\n const elapsed = Math.max(performance.now() - s.startedAt, 1)\n const exits = {\n right: { distance: s.dx, transform: `translateX(calc(${s.dx}px + 150%)) translateY(${s.dy}px) ${s.base}` },\n left: { distance: -s.dx, transform: `translateX(calc(${s.dx}px - 150%)) translateY(${s.dy}px) ${s.base}` },\n down: { distance: s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px + 150%)) ${s.base}` },\n up: { distance: -s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px - 150%)) ${s.base}` },\n }\n const direction = s.directions.find((d) => {\n const distance = exits[d].distance\n return distance > SWIPE_THRESHOLD || (distance > 10 && distance / elapsed > 0.5)\n })\n const base = s.base\n this._endSwipe()\n if (direction) {\n this.element.dataset.swipe = \"end\"\n this.element.dataset.swipeDirection = direction\n this._close(\"dismiss\", exits[direction].transform)\n } else {\n this.element.dataset.swipe = \"cancel\"\n this.element.style.transform = base\n }\n }\n\n _endSwipe({ resume = true } = {}) {\n if (!this._swipe) return\n this._swipe = null\n this.element.removeEventListener(\"pointermove\", this._onPointerMove)\n this.element.removeEventListener(\"pointerup\", this._onPointerUp)\n this.element.removeEventListener(\"pointercancel\", this._onPointerUp)\n if (resume) this._setHold(\"swipe\", false)\n else this._holds.delete(\"swipe\")\n }\n}\n" }, { "path": "toast_description.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastDescription < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"description\"},\n class: \"font-normal leading-[1.4] text-muted-foreground\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # Secondary text; the toast is described by it (aria-describedby, wired by\n # the toast controller). Base UI renders a
.\n class ToastDescription < Base\n def view_template(&)\n p(**attrs, &)\n end\n\n private\n\n def default_attrs\n {data: {slot: \"toast-description\"}, class: \"text-sm text-muted-foreground\"}\n end\n end\nend\n" }, { "path": "toast_icon.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastIcon < Base\n def initialize(variant: nil, **attrs)\n @variant = variant&.to_sym\n super(**attrs)\n end\n\n def view_template\n return unless renderable?\n span(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: \"#{svg_classes} -ml-px\"\n ) { |s| paths(s) }\n end\n end\n\n private\n\n def renderable?\n %i[success error warning info loading].include?(@variant)\n end\n\n def svg_classes\n base = \"size-4\"\n (@variant == :loading) ? \"#{base} animate-spin\" : base\n end\n\n def paths(s)\n case @variant\n when :success\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"m9 12 2 2 4-4\")\n when :error\n 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\")\n s.path(d: \"m15 9-6 6\")\n s.path(d: \"m9 9 6 6\")\n when :warning\n 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\")\n s.path(d: \"M12 9v4\")\n s.path(d: \"M12 17h.01\")\n when :info\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"M12 16v-4\")\n s.path(d: \"M12 8h.01\")\n when :loading\n s.path(d: \"M21 12a9 9 0 1 1-6.219-8.56\")\n end\n end\n\n def default_attrs\n {data: {slot: \"icon\"}, class: \"shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground\"}\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastIcon < Base\n def initialize(variant: nil, **attrs)\n @variant = variant&.to_sym\n super(**attrs)\n end\n\n def view_template\n return unless renderable?\n span(**attrs) do\n svg(\n xmlns: \"http://www.w3.org/2000/svg\",\n width: \"16\",\n height: \"16\",\n viewbox: \"0 0 24 24\",\n fill: \"none\",\n stroke: \"currentColor\",\n stroke_width: \"2\",\n stroke_linecap: \"round\",\n stroke_linejoin: \"round\",\n class: svg_classes,\n aria_hidden: \"true\"\n ) { |s| paths(s) }\n end\n end\n\n private\n\n def renderable?\n %i[success error warning info loading].include?(@variant)\n end\n\n def svg_classes\n case @variant\n when :loading then \"size-4 animate-spin\"\n when :error then \"size-4 text-destructive\"\n else \"size-4\"\n end\n end\n\n def paths(s)\n case @variant\n when :success\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"m9 12 2 2 4-4\")\n when :error\n 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\")\n s.path(d: \"m15 9-6 6\")\n s.path(d: \"m9 9 6 6\")\n when :warning\n 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\")\n s.path(d: \"M12 9v4\")\n s.path(d: \"M12 17h.01\")\n when :info\n s.circle(cx: \"12\", cy: \"12\", r: \"10\")\n s.path(d: \"M12 16v-4\")\n s.path(d: \"M12 8h.01\")\n when :loading\n s.path(d: \"M21 12a9 9 0 1 1-6.219-8.56\")\n end\n end\n\n def default_attrs\n {data: {slot: \"toast-icon\"}, class: \"shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\"}\n end\n end\nend\n" }, { "path": "toast_item.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastItem < Base\n ALERT_VARIANTS = %i[error].freeze\n\n def initialize(\n variant: :default,\n id: nil,\n duration: nil,\n dismissible: true,\n invert: false,\n on_dismiss: nil,\n on_auto_close: nil,\n **attrs\n )\n @variant = variant.to_sym\n @id = id\n @duration = duration\n @dismissible = dismissible\n @invert = invert\n @on_dismiss = on_dismiss\n @on_auto_close = on_auto_close\n super(**attrs)\n end\n\n def view_template(&)\n li(**attrs, &)\n end\n\n private\n\n def default_attrs\n a = {\n role: ALERT_VARIANTS.include?(@variant) ? \"alert\" : \"status\",\n aria_atomic: \"true\",\n tabindex: \"0\",\n data: {\n variant: @variant.to_s,\n state: \"pending\",\n swipe: \"none\",\n controller: \"ruby-ui--toast\",\n ruby_ui__toaster_target: \"toast\",\n ruby_ui__toast_dismissible_value: @dismissible.to_s,\n ruby_ui__toast_invert_value: @invert.to_s\n },\n class: item_classes\n }\n a[:id] = @id if @id\n a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration\n a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss\n a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close\n a\n end\n\n def item_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toast pointer-events-auto absolute left-0 right-0\n flex w-[356px] max-w-full items-center gap-1.5\n rounded-lg border bg-popover text-popover-foreground\n border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)]\n group-data-[close-button=true]/toaster:pr-10\n transition-[transform,opacity] duration-300 ease-out\n will-change-transform\n opacity-[var(--opacity,1)]\n data-[state=pending]:opacity-0\n data-[state=closing]:opacity-0\n data-[swipe=move]:transition-none\n CLASSES\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # One toast. Mirrors shadcn's Base UI Toast (role=dialog, aria-modal=false,\n # named by its title and described by its description) and wraps the block\n # in Base UI's ToastContent row.\n #\n # on_dismiss / on_remove take Stimulus action descriptors (\"undo#commit\")\n # bound to the toast's own ruby-ui:toast:close / ruby-ui:toast:remove events,\n # so the named controller must be on the toast or an ancestor.\n class ToastItem < Base\n PRIORITIES = %i[low high].freeze\n REMOVED_KWARGS = %i[invert on_auto_close].freeze\n\n def initialize(\n variant: :default,\n id: nil,\n duration: nil,\n dismissible: true,\n priority: :low,\n on_dismiss: nil,\n on_remove: nil,\n **attrs\n )\n removed = attrs.keys & REMOVED_KWARGS\n unless removed.empty?\n raise ArgumentError, \"RubyUI::ToastItem: #{removed.map { |k| \"#{k}:\" }.join(\", \")} \" \\\n \"#{(removed.size == 1) ? \"was\" : \"were\"} removed (it never had an effect). See the Toast changelog.\"\n end\n\n @variant = variant.to_sym\n @id = id\n @duration = duration\n @dismissible = dismissible\n @priority = priority.to_sym\n raise ArgumentError, \"RubyUI::ToastItem: priority must be :low or :high\" unless PRIORITIES.include?(@priority)\n\n @on_dismiss = on_dismiss\n @on_remove = on_remove\n super(**attrs)\n end\n\n def view_template(&)\n div(**attrs) do\n div(data: {slot: \"toast-content\"}, class: content_classes, &)\n end\n end\n\n private\n\n def default_attrs\n actions = []\n actions << \"ruby-ui:toast:close->#{@on_dismiss}\" if @on_dismiss\n actions << \"ruby-ui:toast:remove->#{@on_remove}\" if @on_remove\n data = {\n slot: \"toast\",\n type: @variant.to_s,\n variant: @variant.to_s,\n priority: @priority.to_s,\n state: \"pending\",\n swipe: \"none\",\n controller: \"ruby-ui--toast\",\n ruby_ui__toaster_target: \"toast\",\n ruby_ui__toast_dismissible_value: @dismissible.to_s\n }\n data[:action] = actions.join(\" \") unless actions.empty?\n data[:ruby_ui__toast_duration_value] = @duration.to_s if @duration\n a = {\n role: (@priority == :high) ? \"alertdialog\" : \"dialog\",\n aria_modal: \"false\",\n tabindex: \"0\",\n data: data,\n class: item_classes\n }\n a[:id] = @id if @id\n a\n end\n\n # Base UI + vega toast root. The stacking transforms, heights and z-index\n # come from the toaster controller (inline styles), not CSS variables.\n def item_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toast pointer-events-auto absolute inset-x-0 w-full\n rounded-2xl border bg-popover text-popover-foreground shadow-lg\n will-change-transform outline-none select-none\n focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\n transition-[transform,opacity,height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]\n motion-reduce:transition-none\n data-[swipe=move]:transition-none\n data-[state=pending]:[transform:translateY(150%)]\n in-data-[position^=top]:data-[state=pending]:[transform:translateY(-150%)]\n data-[limited]:opacity-0 data-[limited]:pointer-events-none\n after:absolute after:top-full after:left-0 after:h-[13px] after:w-full after:content-['']\n before:absolute before:bottom-full before:left-0 before:h-[13px] before:w-full before:content-['']\n CLASSES\n end\n\n def content_classes\n \"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 \" \\\n \"ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[behind]/toast:opacity-0\"\n end\n end\nend\n" }, { "path": "toast_region.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastRegion < Base\n SKELETON_VARIANTS = %i[default success error warning info loading].freeze\n\n def initialize(\n position: :bottom_right,\n expand: false,\n max: 3,\n duration: 4000,\n gap: 14,\n offset: 24,\n theme: :system,\n rich_colors: false,\n close_button: false,\n hotkey: %w[alt t],\n dir: :ltr,\n flash: nil,\n **attrs\n )\n @position = position.to_sym\n @expand = expand\n @max = max\n @duration = duration\n @gap = gap\n @offset = offset\n @theme = theme.to_sym\n @rich_colors = rich_colors\n @close_button = close_button\n @hotkey = hotkey\n @dir = dir\n @flash = flash\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n ol(id: \"ruby-ui-toaster\", class: \"pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full\") do\n render_flash if @flash\n yield(self) if block\n end\n SKELETON_VARIANTS.each { |v| skeleton(v) }\n slot_template(\"actionTpl\") { render RubyUI::ToastAction.new(label: \"\") }\n slot_template(\"cancelTpl\") { render RubyUI::ToastCancel.new(label: \"\") }\n slot_template(\"closeTpl\") { render RubyUI::ToastClose.new }\n end\n end\n\n private\n\n def render_flash\n @flash.each do |key, message|\n next if message.nil? || message.to_s.empty?\n variant = RubyUI::Toast.flash_variant(key)\n render RubyUI::ToastItem.new(variant: variant, id: \"flash-#{key}\") do\n render RubyUI::ToastIcon.new(variant: variant)\n render RubyUI::ToastTitle.new { message.to_s }\n end\n end\n end\n\n def skeleton(variant)\n template(\n data: {\n ruby_ui__toaster_target: \"skeleton\",\n variant: variant.to_s\n }\n ) do\n render RubyUI::ToastItem.new(variant: variant) do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex flex-col gap-0.5 flex-1 min-w-0\") do\n render RubyUI::ToastTitle.new\n render RubyUI::ToastDescription.new\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def slot_template(target_name, &)\n template(data: {ruby_ui__toaster_target: target_name}, &)\n end\n\n def default_attrs\n {\n id: \"ruby-ui-toaster-region\",\n role: \"region\",\n aria_label: \"Notifications\",\n aria_live: \"polite\",\n data: {\n controller: \"ruby-ui--toaster\",\n turbo_permanent: \"\",\n close_button: @close_button.to_s,\n position: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_position_value: @position.to_s.tr(\"_\", \"-\"),\n ruby_ui__toaster_expand_value: @expand.to_s,\n ruby_ui__toaster_max_value: @max.to_s,\n ruby_ui__toaster_duration_value: @duration.to_s,\n ruby_ui__toaster_gap_value: @gap.to_s,\n ruby_ui__toaster_offset_value: @offset.to_s,\n ruby_ui__toaster_theme_value: @theme.to_s,\n ruby_ui__toaster_rich_colors_value: @rich_colors.to_s,\n ruby_ui__toaster_close_button_value: @close_button.to_s,\n ruby_ui__toaster_hotkey_value: Array(@hotkey).join(\"+\"),\n ruby_ui__toaster_dir_value: @dir.to_s\n },\n class: region_classes\n }\n end\n\n def region_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6\n data-[position=top-left]:top-0 data-[position=top-left]:left-0\n data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2\n data-[position=top-right]:top-0 data-[position=top-right]:right-0\n data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0\n data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2\n data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0\n CLASSES\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # The toast viewport: a fixed, labelled live region that hosts the toasts.\n # Mount it once per page (layout). Mirrors shadcn's Base UI Toaster\n # (toast-viewport); positions other than bottom-right are a RubyUI extra.\n #\n # The region is a popover=\"manual\" so each new toast can lift it into the\n # top layer, above a RubyUI Dialog/Sheet opened with showModal().\n class ToastRegion < Base\n SKELETON_VARIANTS = %i[default success error warning info loading].freeze\n POSITIONS = %i[top_left top_center top_right bottom_left bottom_center bottom_right].freeze\n SWIPE_DIRECTIONS = %i[up down left right].freeze\n REMOVED_KWARGS = %i[offset theme rich_colors dir].freeze\n\n def initialize(\n position: :bottom_right,\n expand: false,\n max: 3,\n duration: 5000,\n gap: 12,\n close_button: true,\n hotkey: %w[F6],\n swipe_directions: nil,\n flash: nil,\n **attrs\n )\n removed = attrs.keys & REMOVED_KWARGS\n unless removed.empty?\n raise ArgumentError, \"RubyUI::ToastRegion: #{removed.map { |k| \"#{k}:\" }.join(\", \")} \" \\\n \"#{(removed.size == 1) ? \"was\" : \"were\"} removed (it never had an effect). See the Toast changelog.\"\n end\n\n @position = position.to_sym\n raise ArgumentError, \"RubyUI::ToastRegion: unknown position #{position.inspect}\" unless POSITIONS.include?(@position)\n\n @expand = expand\n @max = max\n @duration = duration\n @gap = gap\n @close_button = close_button\n @hotkey = hotkey\n @swipe_directions = swipe_directions && Array(swipe_directions).map(&:to_sym)\n unknown = Array(@swipe_directions) - SWIPE_DIRECTIONS\n raise ArgumentError, \"RubyUI::ToastRegion: unknown swipe direction(s) #{unknown.inspect}\" unless unknown.empty?\n @flash = flash\n super(**attrs)\n end\n\n def view_template(&block)\n div(**attrs) do\n div(id: \"ruby-ui-toaster\", class: \"relative\") do\n render_flash if @flash\n yield(self) if block\n end\n SKELETON_VARIANTS.each { |v| skeleton(v) }\n slot_template(\"actionTpl\") { render RubyUI::ToastAction.new(label: \"\") }\n slot_template(\"cancelTpl\") { render RubyUI::ToastCancel.new(label: \"\") }\n slot_template(\"closeTpl\") { render RubyUI::ToastClose.new }\n end\n end\n\n private\n\n def render_flash\n @flash.each do |key, message|\n next if message.nil? || message.to_s.empty?\n variant = RubyUI::Toast.flash_variant(key)\n render RubyUI::ToastItem.new(variant: variant, id: \"flash-#{key}\") do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex min-w-0 flex-1 flex-col gap-1\") do\n render RubyUI::ToastTitle.new { message.to_s }\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def skeleton(variant)\n template(data: {ruby_ui__toaster_target: \"skeleton\", variant: variant.to_s}) do\n render RubyUI::ToastItem.new(variant: variant) do\n render RubyUI::ToastIcon.new(variant: variant)\n div(class: \"flex min-w-0 flex-1 flex-col gap-1\") do\n render RubyUI::ToastTitle.new\n render RubyUI::ToastDescription.new\n end\n render RubyUI::ToastClose.new if @close_button\n end\n end\n end\n\n def slot_template(target_name, &)\n template(data: {ruby_ui__toaster_target: target_name}, &)\n end\n\n def default_attrs\n position = @position.to_s.tr(\"_\", \"-\")\n data = {\n slot: \"toast-viewport\",\n controller: \"ruby-ui--toaster\",\n turbo_permanent: \"\",\n position: position,\n ruby_ui__toaster_position_value: position,\n ruby_ui__toaster_expand_value: @expand.to_s,\n ruby_ui__toaster_max_value: @max.to_s,\n ruby_ui__toaster_duration_value: @duration.to_s,\n ruby_ui__toaster_gap_value: @gap.to_s,\n ruby_ui__toaster_close_button_value: @close_button.to_s,\n ruby_ui__toaster_hotkey_value: Array(@hotkey).join(\"+\")\n }\n # \"none\" disables swipe; leaving it unset derives it from the position.\n if @swipe_directions\n data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.empty? ? \"none\" : @swipe_directions.join(\" \")\n end\n {\n id: \"ruby-ui-toaster-region\",\n role: \"region\",\n aria_label: \"Notifications\",\n aria_live: \"polite\",\n aria_relevant: \"additions text\",\n aria_atomic: \"false\",\n tabindex: \"-1\",\n popover: \"manual\",\n data: data,\n class: region_classes\n }\n end\n\n # Base UI's toast-viewport classes (bottom-right), plus resets for the\n # popover UA styles (inset: 0, margin: auto, border, padding, background)\n # and the other five positions.\n def region_classes\n <<~CLASSES.tr(\"\\n\", \" \").squeeze(\" \").strip\n pointer-events-none fixed z-50 m-0 h-auto w-auto max-w-sm overflow-visible\n border-0 bg-transparent p-0 text-inherit outline-none\n inset-x-4 mx-auto sm:mx-0 sm:w-full\n data-[position^=bottom]:top-auto data-[position^=bottom]:bottom-4\n data-[position^=top]:top-4 data-[position^=top]:bottom-auto\n data-[position$=right]:sm:right-4 data-[position$=right]:sm:left-auto\n data-[position$=left]:sm:left-4 data-[position$=left]:sm:right-auto\n data-[position$=center]:sm:left-1/2 data-[position$=center]:sm:right-auto data-[position$=center]:sm:-translate-x-1/2\n CLASSES\n end\n end\nend\n" }, { "path": "toast_title.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class ToastTitle < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: \"title\"},\n class: \"font-medium leading-normal\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # The toast's heading; the toast is named by it (aria-labelledby, wired by\n # the toast controller). Base UI renders an