Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6002ffe
[Feature] Add Toast component (sonner port) — Phlex side
djalmaaraujo May 8, 2026
0d7f6e7
[Feature] Add Toast Stimulus controllers + docs stub
djalmaaraujo May 8, 2026
d88a551
[Documentation] Wire Toast into docs site
djalmaaraujo May 8, 2026
128874e
[Documentation] Polish Toast docs page + devcontainer compose
djalmaaraujo May 8, 2026
6fd51da
[Documentation] Match shadcn sonner demo style + visual polish
djalmaaraujo May 8, 2026
18a4dd8
[Bug Fix] Mount ToastRegion in DocsLayout
djalmaaraujo May 8, 2026
e6dd60b
[Bug Fix] Move ruby-ui-toaster id from turbo-frame to <ol>
djalmaaraujo May 8, 2026
ca46630
[Bug Fix] Toast stack/expand — proper layout, no trembling, max enforced
djalmaaraujo May 8, 2026
c6d36b8
[Bug Fix] Stop tracking docs/vendor/bundle (devcontainer install path)
djalmaaraujo May 8, 2026
653e120
[Documentation] Ignore docs/vendor/bundle
djalmaaraujo May 8, 2026
1752abd
[Bug Fix] Toast review polish: swap promise icon, drop dead code, mor…
djalmaaraujo May 8, 2026
12a5b9e
[Bug Fix] Anchor toast items per position; add Text Only example
djalmaaraujo May 8, 2026
f10d6c4
[Bug Fix] Honor close_button:true Region prop (was inert)
djalmaaraujo May 8, 2026
1ae58e1
[Bug Fix] ToastTitle font-medium (500), match shadcn sonner
djalmaaraujo May 8, 2026
f379a08
[Bug Fix] cursor-pointer on Toast action/cancel/close buttons
djalmaaraujo May 8, 2026
a82ea29
[Bug Fix] Match shadcn sonner styling tokens with Tailwind utilities
djalmaaraujo May 8, 2026
ab75087
[Bug Fix] Drop overflow-hidden on Toast item
djalmaaraujo May 8, 2026
fc6e71f
[Bug Fix] close_button prop: render only when true; X at top-right in…
djalmaaraujo May 8, 2026
4a69ae2
[Documentation] Add close_button example + API Reference tables
djalmaaraujo May 8, 2026
d2935c5
[Documentation] Reorder Toast docs sections + Close+Action example
djalmaaraujo May 8, 2026
d3e7b44
[Documentation] Move About below Examples in Toast docs
djalmaaraujo May 8, 2026
b9ed98a
[Refactor] Toast aligned with Hotwire best practices
djalmaaraujo May 8, 2026
82d4db9
[Bug Fix] Fix standardrb single-quote offense in toast docs view
djalmaaraujo May 8, 2026
b668372
[Documentation] Wrap Types in VisualCodeExample (Preview/Code tabs)
djalmaaraujo May 8, 2026
b537388
[Refactor] Clone Toast slot buttons from <template> targets
djalmaaraujo May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/.devcontainer/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ services:
dockerfile: .devcontainer/Dockerfile

volumes:
- ../../web:/workspaces/web:cached
- ../..:/workspaces/ruby_ui:cached
working_dir: /workspaces/ruby_ui/docs
ports:
- "3001:3000"

# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
Expand Down
1 change: 1 addition & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

# Ignore bundler config.
/.bundle
/vendor/bundle

# Ignore all logfiles and tempfiles.
/log/*
Expand Down
1 change: 1 addition & 0 deletions docs/app/components/shared/components_list.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def components
{name: "Tabs", path: docs_tabs_path},
{name: "Textarea", path: docs_textarea_path},
{name: "Theme Toggle", path: docs_theme_toggle_path},
{name: "Toast", path: docs_toast_path},
{name: "Tooltip", path: docs_tooltip_path},
{name: "Typography", path: docs_typography_path}
]
Expand Down
58 changes: 58 additions & 0 deletions docs/app/controllers/docs/toast_demo_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

module Docs
class ToastDemoController < ApplicationController
def default = push(:default, "Event scheduled", "Friday at 3:00 PM")

def success = push(:success, "Saved successfully", "Your changes are live.")

def error = push(:error, "Something went wrong", "Please retry.")

def warning = push(:warning, "Heads up", "Storage almost full.")

def info = push(:info, "FYI", "New version available.")

def with_action
render turbo_stream: build_stream(:default, "Email archived", nil, action_label: "Undo")
end

private

def push(variant, title, description)
render turbo_stream: build_stream(variant, title, description)
end

def build_stream(variant, title, description, action_label: nil)
content = ToastFragment.new(
variant: variant,
title: title,
description: description,
action_label: action_label
).call
turbo_stream.append("ruby-ui-toaster", content.html_safe)
end

class ToastFragment < Phlex::HTML
def initialize(variant:, title:, description:, action_label: nil)
@variant = variant
@title = title
@description = description
@action_label = action_label
end

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
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")
end
render RubyUI::ToastClose.new
end
end
end
end
end
4 changes: 4 additions & 0 deletions docs/app/controllers/docs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ def theme_toggle
render Views::Docs::ThemeToggle.new
end

def toast
render Views::Docs::Toast.new
end

def tooltip
render Views::Docs::Tooltip.new
end
Expand Down
9 changes: 9 additions & 0 deletions docs/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { application } from "./application"
import IframeThemeController from "./iframe_theme_controller"
application.register("iframe-theme", IframeThemeController)

import ToastDemoController from "./toast_demo_controller"
application.register("toast-demo", ToastDemoController)

import RubyUi__AccordionController from "./ruby_ui/accordion_controller"
application.register("ruby-ui--accordion", RubyUi__AccordionController)

Expand Down Expand Up @@ -91,6 +94,12 @@ application.register("ruby-ui--tabs", RubyUi__TabsController)
import RubyUi__ThemeToggleController from "./ruby_ui/theme_toggle_controller"
application.register("ruby-ui--theme-toggle", RubyUi__ThemeToggleController)

import RubyUi__ToastController from "./ruby_ui/toast_controller"
application.register("ruby-ui--toast", RubyUi__ToastController)

import RubyUi__ToasterController from "./ruby_ui/toaster_controller"
application.register("ruby-ui--toaster", RubyUi__ToasterController)

import RubyUi__TooltipController from "./ruby_ui/tooltip_controller"
application.register("ruby-ui--tooltip", RubyUi__TooltipController)

Expand Down
151 changes: 151 additions & 0 deletions docs/app/javascript/controllers/ruby_ui/toast_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { Controller } from "@hotwired/stimulus"

const SWIPE_THRESHOLD = 45
const TIME_BEFORE_UNMOUNT = 200

// Connects to data-controller="ruby-ui--toast"
export default class extends Controller {
static values = {
duration: { type: Number, default: 4000 },
dismissible: { type: Boolean, default: true },
invert: { type: Boolean, default: false },
onDismiss: String,
onAutoClose: 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._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._onRestart = () => this._restart()
this._onRegionPause = () => this._pause()
this._onRegionResume = () => this._resume()

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()
})
}

disconnect() {
this._clearTimer()
this.element.removeEventListener("pointerdown", this._onPointerDown)
this.element.removeEventListener("pointerenter", this._onPointerEnter)
this.element.removeEventListener("pointerleave", this._onPointerLeave)
this.element.removeEventListener("keydown", this._onKeyDown)
this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause)
document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume)
}

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)
}

_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)
}

_restart() {
this._clearTimer()
this._start()
}

_pause() {
if (this._paused || !this._timer) return
this._paused = true
clearTimeout(this._timer)
this._timer = null
this._remaining -= performance.now() - this._startedAt
}

_resume() {
if (!this._paused) return
this._paused = false
if (this._remaining <= 0) return this._close("auto")
this._startedAt = performance.now()
this._timer = setTimeout(() => this._close("auto"), this._remaining)
}

_clearTimer() {
if (this._timer) clearTimeout(this._timer)
this._timer = null
}

_onKeyDown(e) {
if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e)
}

_onPointerDown(e) {
if (!this.dismissibleValue) return
if (e.target.closest("button")) 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.element.addEventListener("pointermove", this._onPointerMove)
this.element.addEventListener("pointerup", this._onPointerUp)
this.element.addEventListener("pointercancel", this._onPointerUp)
}

_onPointerMove(e) {
const dx = e.clientX - this._swipe.x
const dy = e.clientY - this._swipe.y
this.element.dataset.swipe = "move"
this.element.style.transform = `translate(${dx}px, ${dy}px)`
}

_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`)
this.element.dataset.swipe = "end"
this.element.style.transform = ""
this._close("dismiss")
} else {
this.element.dataset.swipe = "cancel"
this.element.style.transform = ""
this._resume()
}
}
}
Loading