From e03a12a491f1a4a8b1fcefc5e56911010a2a008e Mon Sep 17 00:00:00 2001 From: Cirdes Date: Wed, 4 Nov 2026 12:03:28 -0300 Subject: [PATCH 1/4] [Feature] Toast: match shadcn (Base UI + vega) Align RubyUI's Toast with shadcn's Base UI Toast (bases/base/ui/toast.tsx) and the vega style instead of the sonner port it started from: - viewport: fixed bottom-4 right-4, max-w-sm (inset-x-4 on mobile); card rounded-2xl shadow-lg, content row p-4 gap-3, text-sm title/description - ToastAction is a Button outline sm, ToastClose a ghost icon-sm Button in the row and shown by default; ToastCancel becomes a ghost sm Button - semantics: each toast is role=dialog aria-modal=false named/described by an h2 title and p description; a single polite region; priority: :high uses role=alertdialog plus a hidden role=alert announcement - stack: peek 12px, scale -10% per step, hidden content behind, gap 12; toasts past max stay mounted (data-limited) and come back - timers pause on hover, keyboard focus and window blur; default 5000ms; the region's duration now applies to spawned toasts; Esc no longer dismisses; F6 focuses the newest toast - swipe dismisses down/right (from position, or swipe_directions:) - slide-in/out from translateY(150%), node removed on transitionend - the region is popover="manual" so toasts paint above showModal() dialogs - on_dismiss/on_remove bind ruby-ui:toast:close/remove; priority: and on_remove: added; JS API gains onClose/onRemove/priority/close Breaking: ToastRegion offset:, theme:, rich_colors:, dir: and ToastItem invert:, on_auto_close: are removed (they never had an effect) and raise ArgumentError; data-slot names are now toast-*. Co-Authored-By: Claude Opus 5.5 --- .../controllers/docs/toast_demo_controller.rb | 4 +- .../controllers/toast_demo_controller.js | 5 +- docs/app/views/docs/toast.rb | 84 ++-- gem/lib/generators/ruby_ui/dependencies.yml | 3 + gem/lib/ruby_ui/toast/toast_action.rb | 20 +- gem/lib/ruby_ui/toast/toast_cancel.rb | 18 +- gem/lib/ruby_ui/toast/toast_close.rb | 25 +- gem/lib/ruby_ui/toast/toast_controller.js | 247 +++++++---- gem/lib/ruby_ui/toast/toast_description.rb | 9 +- gem/lib/ruby_ui/toast/toast_icon.rb | 12 +- gem/lib/ruby_ui/toast/toast_item.rb | 91 ++-- gem/lib/ruby_ui/toast/toast_region.rb | 106 +++-- gem/lib/ruby_ui/toast/toast_title.rb | 9 +- gem/lib/ruby_ui/toast/toaster_controller.js | 404 +++++++++++------- gem/test/ruby_ui/toast_item_test.rb | 54 ++- gem/test/ruby_ui/toast_region_test.rb | 63 ++- gem/test/ruby_ui/toast_test.rb | 43 +- mcp/data/registry/components/toast.json | 24 +- 18 files changed, 798 insertions(+), 423 deletions(-) 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..e62931744 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 newest toast." 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)", description: "Default lifetime per toast (Base UI timeout). 0 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 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."}, {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 | nil", description: "Override the region default. nil inherits it; 0 stays until dismissed."}, + {name: "dismissible", default: "true", values: "Boolean", description: "Allow swipe, the X, Cancel and dismiss-all to close it."}, + {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: "Disable swipe / X / dismiss-all for this toast."}, + {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..998f84db5 100644 --- a/gem/lib/ruby_ui/toast/toast_controller.js +++ b/gem/lib/ruby_ui/toast/toast_controller.js @@ -1,151 +1,244 @@ 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.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) - - requestAnimationFrame(() => { - this.element.dataset.state = "open" - this._start() - }) + this.element.addEventListener("ruby-ui:toast:hold", this._onHold) + + 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._clearTimer() + this._endSwipe() 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) } + // 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. + _wireLabels() { + const title = this.element.querySelector('[data-slot="toast-title"]') + const description = this.element.querySelector('[data-slot="toast-description"]') + const base = this.element.id || `ruby-ui-toast-${++uid}` + if (title && title.textContent.trim() !== "") { + title.id ||= `${base}-title` + this.element.setAttribute("aria-labelledby", title.id) + } + if (description && description.textContent.trim() !== "") { + description.id ||= `${base}-description` + this.element.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" + 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)` }, + left: { distance: -s.dx, transform: `translateX(calc(${s.dx}px - 150%)) translateY(${s.dy}px)` }, + down: { distance: s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px + 150%))` }, + up: { distance: -s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px - 150%))` }, + } + 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() { + 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) + this._setHold("swipe", false) + } } 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..111d7409e 100644 --- a/gem/lib/ruby_ui/toast/toast_region.rb +++ b/gem/lib/ruby_ui/toast/toast_region.rb @@ -1,42 +1,53 @@ # 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) @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 +66,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 +92,48 @@ 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("+") + } + data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.join(" ") if @swipe_directions { 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..3d86198af 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,266 @@ 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 front toast 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() } connect() { - this._expanded = this.expandValue - this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) + 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._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) document.addEventListener("keydown", this._onKey) + 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) document.removeEventListener("keydown", this._onKey) - this._listEl = null + this._holds.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.element.querySelector(`[data-announce-for="${CSS.escape(el.id)}"]`)?.remove() 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 } })) + ) + } + + // ---- 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) { + 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.announceFor = el.id + node.textContent = [title, description].filter(Boolean).join(". ") + this.element.appendChild(node) + if (!this._holds.has("focus")) el.setAttribute("aria-hidden", "true") + } + + _syncHighPriorityVisibility(focused) { + 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 +292,62 @@ 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) { + node.setAttribute("data-ruby-ui--toast-swipe-directions-value", [].concat(detail.swipeDirections).join(" ")) } - 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 +358,33 @@ 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 })) - } - }) - } - + // Focus the front toast (Base UI: F6 moves focus to the viewport's first + // focusable 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 + const front = this.toastTargets + .filter((el) => el.dataset.state !== "closing" && !el.hasAttribute("data-limited")) + .at(-1) + if (!front) return e.preventDefault() - const first = this._listEl?.firstElementChild - first?.focus() + front.focus() } _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 +396,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 +414,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..7a57008cb 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,11 @@ def test_duration_propagated_as_data_value assert_match(/data-ruby-ui--toast-duration-value="7000"/, out) end + def test_duration_omitted_inherits_region + 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 +62,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..3bd57b953 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(/
    .*data-slot="toast-close"/m, out) end - def test_role_region_aria_live - out = phlex { RubyUI.ToastRegion() } - assert_match(/role="region"/, out) - assert_match(/aria-live="polite"/, out) + def test_close_button_false_drops_the_x_from_skeletons + out = phlex { RubyUI.ToastRegion(close_button: false) } + skeleton = out[/