Skip to content

[Render Preview] [Feature] Toast (sonner port, Hotwire-native)#389

Open
djalmaaraujo wants to merge 25 commits intomainfrom
da/sonner-hotwire
Open

[Render Preview] [Feature] Toast (sonner port, Hotwire-native)#389
djalmaaraujo wants to merge 25 commits intomainfrom
da/sonner-hotwire

Conversation

@djalmaaraujo
Copy link
Copy Markdown
Contributor

@djalmaaraujo djalmaaraujo commented May 8, 2026

Summary

Toast-sonner-small.mp4

Preview

https://web-1-pr-389.onrender.com/ (might not be available all the time)

Description

Hotwire-native port of shadcn/ui's sonner (originally backed by emilkowalski/sonner) implemented as a single Toast component family in RubyUI.

  • Two trigger paths. Server-pushed via Turbo Stream append into <turbo-frame id="ruby-ui-toaster">, or client-side via window.RubyUI.toast.{success,error,warning,info,loading,dismiss,promise} (registered by the Toaster Stimulus controller on connect).
  • Full sonner parity: variants (default/success/error/warning/info/loading), action + cancel buttons, auto-dismiss with hover-pause, stack with expand-on-hover, swipe-to-dismiss (pointer events), promise toasts, configurable position (6), theme/rich-colors hooks, hotkey to focus region, Escape to dismiss focused, ARIA roles (status vs alert).
  • Pure Tailwind utilities — no custom CSS file. Reuses RubyUI design tokens and tailwindcss-animate. Per-toast offsets via inline CSS variables only.
  • Rails flash bridge via turbo_frame_tag refresh: :morph (jetrockets pattern).
  • Tests follow the existing ComponentTest rendering pattern only — no new test category. 175/175 green; standardrb clean.

Files

Gem:

  • gem/lib/ruby_ui/toast/ — Region, Item, Title, Description, Icon, Action, Cancel, Close + 2 Stimulus controllers (toaster_controller.js, toast_controller.js).
  • gem/test/ruby_ui/toast{,_item,_region}_test.rb.
  • gem/lib/generators/ruby_ui/dependencies.ymltoast: entry.

Docs site:

  • Mount RubyUI::ToastRegion.new in application_layout.rb.
  • app/views/docs/toast.rb — full docs page (mount, variants, server-pushed, JS API, position, flash bridge).
  • app/controllers/docs/toast_demo_controller.rb — turbo_stream.append endpoints for live demos.
  • app/javascript/controllers/toast_demo_controller.js — Stimulus controller for JS-API buttons (Phlex blocks onclick).
  • Stimulus index: register ruby-ui--toast and ruby-ui--toaster.
  • Sidebar entry under "Toast".

Spec & plan

  • specs/2026-05-08-toast-hotwire-design.md
  • specs/2026-05-08-toast-hotwire-plan.md

(These specs are not committed; they're working docs.)

Test plan

  • cd gem && bundle exec rake — 175 runs, 749 assertions, 0 failures, 0 errors. StandardRB clean.
  • Brought up docs/.devcontainer (compose updated for monorepo layout: mounts repo at /workspaces/ruby_ui, working_dir docs, ports 3001:3000).
  • GET /docs/toast → 200, region mounted, 6 variant skeletons present.
  • POST /docs/toast_demo/success → 200, returns <turbo-stream action="append" target="ruby-ui-toaster"> with valid Toast::Item HTML.
  • Reviewer — open http://localhost:3001/docs/toast after cd docs/.devcontainer && docker compose up -d and verify visually:
    • Variants buttons spawn correct toast (icon + role).
    • Auto-dismiss after ~4s; hover pauses; leave resumes.
    • Stack of 3+ collapses; hover expands; leave collapses.
    • Swipe (drag) horizontally on a toast > 45px → dismisses; small drag → snaps back.
    • Action button click dismisses + emits ruby-ui:toast:dismiss.
    • Close X visible on hover.
    • RubyUI.toast.promise(...) from console → loading → success transition.
    • Alt+T focuses first toast; Escape dismisses focused.
    • Position prop variants (:top_right, :top_center, etc.) place region correctly.
    • Dark mode flips colors via existing tokens.

Notes for reviewer

  • The docs site mounts the Region globally; toasts are universal across the docs.
  • docs/.devcontainer/compose.yaml was rewired for the monorepo layout (was pointing at the legacy sibling web/ repo). If you use VSCode "Reopen in Container" this should still work.
  • The gem ships zero new npm dependencies.

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.
- 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.
- 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.
- 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).
@djalmaaraujo djalmaaraujo requested a review from cirdes as a code owner May 8, 2026 17:14
- 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.
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.
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.
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.
…e 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.
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.
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.
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.
Close button uses -translate-x-[35%] -translate-y-[35%] to sit
outside the top-left corner; overflow-hidden was clipping it.
…side

- 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.
- 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.
- 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).
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.
@djalmaaraujo djalmaaraujo changed the title [Feature] Toast (sonner port, Hotwire-native) [Render Preview] [Feature] Toast (sonner port, Hotwire-native) May 8, 2026
@djalmaaraujo djalmaaraujo temporarily deployed to da/sonner-hotwire - web-1 PR #389 May 8, 2026 19:38 — with Render Destroyed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant