Skip to content

Commit 1c1666a

Browse files
authored
[Render Preview] [Feature] Toast (sonner port, Hotwire-native) (#389)
* [Feature] Add Toast component (sonner port) — Phlex side Adds RubyUI::Toast{Region,Item,Title,Description,Icon,Action,Cancel,Close} with full variant set (default/success/error/warning/info/loading), inline lucide SVG icons, role/aria mapping, Rails flash bridge, and per-variant hidden <template> skeletons inside a turbo-frame for client-side spawning. No custom CSS — pure Tailwind utilities + tailwindcss-animate. * [Feature] Add Toast Stimulus controllers + docs stub - toast_controller: per-item lifecycle, hover-pause, swipe-to-dismiss, Escape key, force-dismiss + restart events. - toaster_controller: stack/expand-on-hover, MutationObserver for Turbo Stream appends, window.RubyUI.toast JS API (success/error/ warning/info/loading/dismiss/promise), hotkey to focus region. - toast_docs.rb stub. - Register toast in dependencies.yml. * [Documentation] Wire Toast into docs site - Mount RubyUI::ToastRegion globally in application layout. - Add /docs/toast page with usage, variants, server-pushed, JS API, position, and Rails flash bridge examples. - Add Docs::ToastDemoController with turbo_stream.append endpoints (default/success/error/warning/info/with_action) for live demo. - Register Stimulus controllers (ruby-ui--toast, ruby-ui--toaster). - Add Toast to sidebar components list. * [Documentation] Polish Toast docs page + devcontainer compose - Replace onclick handlers with Stimulus toast-demo controller (Phlex blocks unsafe inline event attrs). - Move server-push and JS API examples to plain Codeblocks; reserve VisualCodeExample for snippets that work under instance_eval. - Wire docs/.devcontainer/compose.yaml to monorepo layout (mounts ruby_ui root, working_dir=docs, ports 3001:3000). * [Documentation] Match shadcn sonner demo style + visual polish - Drop variant-tinted borders; icons monochrome (currentColor) — matches shadcn sonner exactly. - Rewrite docs page: shadcn-style "Examples > Types" grid (2-col) with one box per variant, each containing a 'Show toast' button. - Position section: 6-button grid; click spawns toast in chosen corner via new `position` override in spawn detail (toaster_controller swaps data-position before spawning). - JS API section explains it's sugar over a window CustomEvent (Hotwire-friendly: any source can dispatch `ruby-ui:toast`). - Server-pushed example: fix button_to to use form-level data-turbo-stream. - toaster_controller: register window.RubyUI.toast earlier so click handlers don't race with controller connect. * [Bug Fix] Mount ToastRegion in DocsLayout DocsController uses DocsLayout, not ApplicationLayout — without this, the Region was missing on every /docs/* page, so window.RubyUI.toast was never registered (Toaster Stimulus controller never connected) and Turbo Stream appends had no target. * [Bug Fix] Move ruby-ui-toaster id from turbo-frame to <ol> turbo_stream.append('ruby-ui-toaster') was injecting toasts INSIDE the turbo-frame wrapper but as siblings of the <ol>. The MutationObserver in toaster_controller watches the <ol>, so new toasts were never tracked and never animated in. Drop the turbo-frame wrapper (refresh:morph wasn't actually wired to flash on navigation anyway) and put the target id on the <ol> itself. Now Turbo Stream appends land where the controller can see them. * [Bug Fix] Toast stack/expand — proper layout, no trembling, max enforced Items are now position:absolute inside a relative <ol>; transforms applied via inline style (no flex layout shift on hover). Stack behavior matches sonner: - Collapsed: front toast full-size; rear toasts peek 16px upward each, scaled 0.95/0.90, opacity 0.8/0.6 — visible as cards behind. - Expanded (hover): all toasts spread by their actual heights + gap; full opacity + scale. - OL min-height set to expandedHeight (when expanded) or collapsedHeight (when collapsed) so hover hit-area is stable — no enter/leave flicker. Region restructured: outer <div data-controller> wrapping <ol id> + <template> skeletons. Skeletons must be descendants of controller for Stimulus targets to resolve (this was breaking JS API spawning). Auto-dismiss when toast count exceeds max (default 3): oldest items get force-dismiss, exit anim runs, DOM cleans up. * [Bug Fix] Stop tracking docs/vendor/bundle (devcontainer install path) * [Documentation] Ignore docs/vendor/bundle * [Bug Fix] Toast review polish: swap promise icon, drop dead code, more tests Issues found and fixed during implementation review: - _mutate (promise transitions) now swaps the icon SVG to match the resolved variant, and updates aria role for error case. Previously loading -> success kept the spinner. - Drop unused dataset bracket-key assignment in _spawn (only the setAttribute path was effective). - _dismissById iterates the tracked _items list rather than raw OL children (defensive against non-toast nodes). - Drop unused POSITIONS constant from ToastRegion. - Add Minitest coverage for ToastAction label/data-action, ToastCancel label, ToastClose dismiss action + sr-only, ToastIcon per-variant SVG content, ToastTitle/Description slots. 181 runs, 787 assertions, 0 failures. * [Bug Fix] Anchor toast items per position; add Text Only example Items now anchor inline to top:0 (top positions) or bottom:0 (bottom positions) via the toaster controller's reflow. Previously every item was hardcoded to bottom:0 in the Tailwind class list, so for top-* positions the OL grew downward on hover, pulling the front toast out from under the cursor and causing an enter/leave thrash. Add a Text Only example after Promise: default variant with title only, no description, no icon, no action — matches shadcn sonner's Text Only demo. * [Bug Fix] Honor close_button:true Region prop (was inert) Region wrapper now carries data-close-button=hover|always plus a group/toaster anchor. Toast::Close opacity rule extended: group-data-[close-button=always]/toaster:opacity-100 — when consumer sets close_button: true on the Region, the X is always visible (better a11y for touch + cognitive accessibility). Default stays close_button: false (hover/focus reveal) to match shadcn sonner. Keyboard a11y unchanged: tab focuses item, focus shows button, Escape dismisses. * [Bug Fix] ToastTitle font-medium (500), match shadcn sonner * [Bug Fix] cursor-pointer on Toast action/cancel/close buttons * [Bug Fix] Match shadcn sonner styling tokens with Tailwind utilities Item: - gap-3 -> gap-1.5 (6px, sonner) - text-[13px] (sonner font-size) - p-4 only (close moved out of right side via translate) - shadow-lg -> shadow-[0_4px_12px_rgba(0,0,0,0.1)] (matches sonner) Title: drop text-sm so it inherits 13px; leading-tight -> leading-normal. Description: drop text-sm; add font-normal leading-[1.4]. Content wrapper (skeleton): gap-1 -> gap-0.5 (2px). Icon span: justify-start, relative, size-4, -ml-[3px] mr-1 (sonner icon margins). SVG: add -ml-px. Action button: solid dark style — h-6 px-2 text-xs rounded bg-foreground text-background ml-auto hover:opacity-90. Cancel button: soft fill — bg-foreground/10 text-foreground ml-auto hover:bg-foreground/15. Close button: moved to top-left with -translate-x-[35%] -translate-y-[35%]; size-5 rounded-full border bg-popover; SVG size-3. Toaster JS: spawn-time action/cancel buttons mirror new classes. Docs: shorten Header description; move triggering blurb + sonner credit to an About section after Installation tabs. * [Bug Fix] Drop overflow-hidden on Toast item Close button uses -translate-x-[35%] -translate-y-[35%] to sit outside the top-left corner; overflow-hidden was clipping it. * [Bug Fix] close_button prop: render only when true; X at top-right inside - Region.skeleton: render ToastClose only when close_button: true. - Region wrapper: data-close-button=true|false. - Item: group-data-[close-button=true]/toaster:pr-10 reserves space. - ToastClose: top-right inside (right-2 top-2), size-6 rounded-md, always visible, ghost-button style. SVG bumped to size-3.5. - Default close_button: false (no X shown). Set to true on Region to opt in. * [Documentation] Add close_button example + API Reference tables - New 'Close Button' example box: clicking spawns a toast with X visible at top-right (top of stack inside item). - Toaster JS spawn now honors detail.closeButton: appends a top-right ghost X button and adds pr-10 to the cloned node. - API Reference section after About: - Toaster (Region) props: 12 entries with default + values + desc. - ToastItem props: 7 entries. - JS API options: 10 entries (callable as RubyUI.toast.* or via ruby-ui:toast CustomEvent detail). - Reusable inline props_table helper; uses RubyUI Table primitives. * [Documentation] Reorder Toast docs sections + Close+Action example - About moved to top (right after Header). - Examples second; Mount moved to AFTER Examples. - New 'Close + Action' example after 'Close Button' (X visible plus Undo button). - Components table moved above API Reference (file table first, then the props/options reference). * [Documentation] Move About below Examples in Toast docs * [Refactor] Toast aligned with Hotwire best practices Critical review against the Hotwire skill references and refactored to the more idiomatic patterns: 1. **Target callbacks instead of MutationObserver** (toaster). Toast items now carry data-ruby-ui--toaster-target="toast". Stimulus fires toastTargetConnected/toastTargetDisconnected automatically; the manual MutationObserver is gone. Cleaner, matches the superpowers reference: 2024-05-07-stimulus-target-callbacks. 2. **data-turbo-permanent on the Region wrapper** so the toaster survives Turbo Drive navigations (in-flight toasts no longer get wiped when the user clicks a link). Outer <div> now also carries a stable id="ruby-ui-toaster-region" so morph + permanent reconciles correctly. Reference: hwc-navigation-content cache lifecycle. 3. **Custom Turbo Stream action **. StreamActions.toast registers once at controller connect; reads variant/title/description/duration/id from the <turbo-stream> attributes and dispatches the standard ruby-ui:toast event. Server-side becomes ergonomic: turbo_stream.action(:toast, target: 'ruby-ui-toaster', variant: :success, title: 'Saved') The append-with-rendered-Item path is still supported for cases needing Action/Cancel slots. Reference: 2023-08-15-turbo-streams-custom-stream-actions. 4. **JS API now dispatches CustomEvent instead of calling _spawn directly.** Decoupled — any listener (including future multi-region setups) sees the same ruby-ui:toast events. window.RubyUI.toast.* stays as sugar over window.dispatchEvent. 5. Visible-only items get tabindex=-1 in _reflow for keyboard a11y. 182 runs, 793 assertions, 0 failures. * [Bug Fix] Fix standardrb single-quote offense in toast docs view * [Documentation] Wrap Types in VisualCodeExample (Preview/Code tabs) * [Refactor] Clone Toast slot buttons from <template> targets Drops ~25 lines of hardcoded Tailwind classes from toaster_controller.js. Phlex now owns the single source of truth for ToastAction, ToastCancel, and ToastClose styling. - Region renders three additional <template>s next to the variant skeletons: actionTpl, cancelTpl, closeTpl. Each renders the corresponding Phlex component once, with default classes. - Toaster controller declares actionTplTarget / cancelTplTarget / closeTplTarget. _spawn clones from these instead of building DOM elements with hand-written className strings. - _cloneSlot helper centralizes the clone-firstElementChild pattern. Both delivery paths benefit from a single Tailwind source: - Server-pushed (Turbo Stream append) — already used Phlex Items end-to-end; unchanged. - Client-spawned (window.RubyUI.toast.*) — now also Phlex-sourced. Update Tailwind/style tweaks in Action/Cancel/Close .rb files and JS picks them up automatically; @source scan continues to see the classes in the gem files.
1 parent d737706 commit 1c1666a

29 files changed

Lines changed: 1895 additions & 1 deletion

docs/.devcontainer/compose.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ services:
77
dockerfile: .devcontainer/Dockerfile
88

99
volumes:
10-
- ../../web:/workspaces/web:cached
10+
- ../..:/workspaces/ruby_ui:cached
11+
working_dir: /workspaces/ruby_ui/docs
12+
ports:
13+
- "3001:3000"
1114

1215
# Overrides default command so things don't shut down after the process ends.
1316
command: sleep infinity

docs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
# Ignore bundler config.
88
/.bundle
9+
/vendor/bundle
910

1011
# Ignore all logfiles and tempfiles.
1112
/log/*

docs/app/components/shared/components_list.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def components
5050
{name: "Tabs", path: docs_tabs_path},
5151
{name: "Textarea", path: docs_textarea_path},
5252
{name: "Theme Toggle", path: docs_theme_toggle_path},
53+
{name: "Toast", path: docs_toast_path},
5354
{name: "Tooltip", path: docs_tooltip_path},
5455
{name: "Typography", path: docs_typography_path}
5556
]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module Docs
4+
class ToastDemoController < ApplicationController
5+
def default = push(:default, "Event scheduled", "Friday at 3:00 PM")
6+
7+
def success = push(:success, "Saved successfully", "Your changes are live.")
8+
9+
def error = push(:error, "Something went wrong", "Please retry.")
10+
11+
def warning = push(:warning, "Heads up", "Storage almost full.")
12+
13+
def info = push(:info, "FYI", "New version available.")
14+
15+
def with_action
16+
render turbo_stream: build_stream(:default, "Email archived", nil, action_label: "Undo")
17+
end
18+
19+
private
20+
21+
def push(variant, title, description)
22+
render turbo_stream: build_stream(variant, title, description)
23+
end
24+
25+
def build_stream(variant, title, description, action_label: nil)
26+
content = ToastFragment.new(
27+
variant: variant,
28+
title: title,
29+
description: description,
30+
action_label: action_label
31+
).call
32+
turbo_stream.append("ruby-ui-toaster", content.html_safe)
33+
end
34+
35+
class ToastFragment < Phlex::HTML
36+
def initialize(variant:, title:, description:, action_label: nil)
37+
@variant = variant
38+
@title = title
39+
@description = description
40+
@action_label = action_label
41+
end
42+
43+
def view_template
44+
render RubyUI::ToastItem.new(variant: @variant) do
45+
render RubyUI::ToastIcon.new(variant: @variant)
46+
div(class: "flex flex-col gap-1 flex-1 min-w-0") do
47+
render RubyUI::ToastTitle.new { @title }
48+
render(RubyUI::ToastDescription.new { @description }) if @description
49+
end
50+
if @action_label
51+
render RubyUI::ToastAction.new(label: @action_label, on: "click->ruby-ui--toast#dismiss")
52+
end
53+
render RubyUI::ToastClose.new
54+
end
55+
end
56+
end
57+
end
58+
end

docs/app/controllers/docs_controller.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ def theme_toggle
222222
render Views::Docs::ThemeToggle.new
223223
end
224224

225+
def toast
226+
render Views::Docs::Toast.new
227+
end
228+
225229
def tooltip
226230
render Views::Docs::Tooltip.new
227231
end

docs/app/javascript/controllers/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { application } from "./application"
77
import IframeThemeController from "./iframe_theme_controller"
88
application.register("iframe-theme", IframeThemeController)
99

10+
import ToastDemoController from "./toast_demo_controller"
11+
application.register("toast-demo", ToastDemoController)
12+
1013
import RubyUi__AccordionController from "./ruby_ui/accordion_controller"
1114
application.register("ruby-ui--accordion", RubyUi__AccordionController)
1215

@@ -94,6 +97,12 @@ application.register("ruby-ui--tabs", RubyUi__TabsController)
9497
import RubyUi__ThemeToggleController from "./ruby_ui/theme_toggle_controller"
9598
application.register("ruby-ui--theme-toggle", RubyUi__ThemeToggleController)
9699

100+
import RubyUi__ToastController from "./ruby_ui/toast_controller"
101+
application.register("ruby-ui--toast", RubyUi__ToastController)
102+
103+
import RubyUi__ToasterController from "./ruby_ui/toaster_controller"
104+
application.register("ruby-ui--toaster", RubyUi__ToasterController)
105+
97106
import RubyUi__TooltipController from "./ruby_ui/tooltip_controller"
98107
application.register("ruby-ui--tooltip", RubyUi__TooltipController)
99108

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
const SWIPE_THRESHOLD = 45
4+
const TIME_BEFORE_UNMOUNT = 200
5+
6+
// Connects to data-controller="ruby-ui--toast"
7+
export default class extends Controller {
8+
static values = {
9+
duration: { type: Number, default: 4000 },
10+
dismissible: { type: Boolean, default: true },
11+
invert: { type: Boolean, default: false },
12+
onDismiss: String,
13+
onAutoClose: String,
14+
}
15+
16+
connect() {
17+
this._timer = null
18+
this._startedAt = 0
19+
this._remaining = this.durationValue
20+
this._paused = false
21+
this._swipe = { active: false, x: 0, y: 0, startedAt: 0 }
22+
23+
this._onPointerDown = this._onPointerDown.bind(this)
24+
this._onPointerMove = this._onPointerMove.bind(this)
25+
this._onPointerUp = this._onPointerUp.bind(this)
26+
this._onPointerEnter = () => this._pause()
27+
this._onPointerLeave = () => { if (!this._swipe.active) this._resume() }
28+
this._onKeyDown = this._onKeyDown.bind(this)
29+
this._onForceDismiss = (e) => { e.stopPropagation(); this._close() }
30+
this._onRestart = () => this._restart()
31+
this._onRegionPause = () => this._pause()
32+
this._onRegionResume = () => this._resume()
33+
34+
this.element.addEventListener("pointerdown", this._onPointerDown)
35+
this.element.addEventListener("pointerenter", this._onPointerEnter)
36+
this.element.addEventListener("pointerleave", this._onPointerLeave)
37+
this.element.addEventListener("keydown", this._onKeyDown)
38+
this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
39+
this.element.addEventListener("ruby-ui:toast:restart", this._onRestart)
40+
document.addEventListener("ruby-ui:toast:pause", this._onRegionPause)
41+
document.addEventListener("ruby-ui:toast:resume", this._onRegionResume)
42+
43+
requestAnimationFrame(() => {
44+
this.element.dataset.state = "open"
45+
this._start()
46+
})
47+
}
48+
49+
disconnect() {
50+
this._clearTimer()
51+
this.element.removeEventListener("pointerdown", this._onPointerDown)
52+
this.element.removeEventListener("pointerenter", this._onPointerEnter)
53+
this.element.removeEventListener("pointerleave", this._onPointerLeave)
54+
this.element.removeEventListener("keydown", this._onKeyDown)
55+
this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
56+
this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
57+
document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause)
58+
document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume)
59+
}
60+
61+
dismiss(e) {
62+
e?.preventDefault()
63+
if (!this.dismissibleValue) return
64+
this._close("dismiss")
65+
}
66+
67+
_close(reason) {
68+
if (this.element.dataset.state === "closing") return
69+
this.element.dataset.state = "closing"
70+
this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } }))
71+
setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT)
72+
}
73+
74+
_start() {
75+
if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return
76+
this._startedAt = performance.now()
77+
this._remaining = this.durationValue
78+
this._timer = setTimeout(() => this._close("auto"), this._remaining)
79+
}
80+
81+
_restart() {
82+
this._clearTimer()
83+
this._start()
84+
}
85+
86+
_pause() {
87+
if (this._paused || !this._timer) return
88+
this._paused = true
89+
clearTimeout(this._timer)
90+
this._timer = null
91+
this._remaining -= performance.now() - this._startedAt
92+
}
93+
94+
_resume() {
95+
if (!this._paused) return
96+
this._paused = false
97+
if (this._remaining <= 0) return this._close("auto")
98+
this._startedAt = performance.now()
99+
this._timer = setTimeout(() => this._close("auto"), this._remaining)
100+
}
101+
102+
_clearTimer() {
103+
if (this._timer) clearTimeout(this._timer)
104+
this._timer = null
105+
}
106+
107+
_onKeyDown(e) {
108+
if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e)
109+
}
110+
111+
_onPointerDown(e) {
112+
if (!this.dismissibleValue) return
113+
if (e.target.closest("button")) return
114+
try { this.element.setPointerCapture(e.pointerId) } catch {}
115+
this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId }
116+
this.element.dataset.swipe = "start"
117+
this.element.addEventListener("pointermove", this._onPointerMove)
118+
this.element.addEventListener("pointerup", this._onPointerUp)
119+
this.element.addEventListener("pointercancel", this._onPointerUp)
120+
}
121+
122+
_onPointerMove(e) {
123+
const dx = e.clientX - this._swipe.x
124+
const dy = e.clientY - this._swipe.y
125+
this.element.dataset.swipe = "move"
126+
this.element.style.transform = `translate(${dx}px, ${dy}px)`
127+
}
128+
129+
_onPointerUp(e) {
130+
const dx = e.clientX - this._swipe.x
131+
const dy = e.clientY - this._swipe.y
132+
const dist = Math.hypot(dx, dy)
133+
const dt = performance.now() - this._swipe.startedAt
134+
const velocity = dist / Math.max(dt, 1)
135+
this.element.removeEventListener("pointermove", this._onPointerMove)
136+
this.element.removeEventListener("pointerup", this._onPointerUp)
137+
this.element.removeEventListener("pointercancel", this._onPointerUp)
138+
this._swipe.active = false
139+
if (dist > SWIPE_THRESHOLD || velocity > 0.5) {
140+
this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`)
141+
this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`)
142+
this.element.dataset.swipe = "end"
143+
this.element.style.transform = ""
144+
this._close("dismiss")
145+
} else {
146+
this.element.dataset.swipe = "cancel"
147+
this.element.style.transform = ""
148+
this._resume()
149+
}
150+
}
151+
}

0 commit comments

Comments
 (0)