[Render Preview] [Feature] Toast (sonner port, Hotwire-native)#389
Open
djalmaaraujo wants to merge 25 commits intomainfrom
Open
[Render Preview] [Feature] Toast (sonner port, Hotwire-native)#389djalmaaraujo wants to merge 25 commits intomainfrom
djalmaaraujo wants to merge 25 commits intomainfrom
Conversation
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).
- 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 byemilkowalski/sonner) implemented as a singleToastcomponent family in RubyUI.appendinto<turbo-frame id="ruby-ui-toaster">, or client-side viawindow.RubyUI.toast.{success,error,warning,info,loading,dismiss,promise}(registered by the Toaster Stimulus controller on connect).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,Escapeto dismiss focused, ARIA roles (statusvsalert).tailwindcss-animate. Per-toast offsets via inline CSS variables only.flashbridge viaturbo_frame_tag refresh: :morph(jetrockets pattern).ComponentTestrendering 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.yml—toast:entry.Docs site:
RubyUI::ToastRegion.newinapplication_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 blocksonclick).ruby-ui--toastandruby-ui--toaster.Spec & plan
specs/2026-05-08-toast-hotwire-design.mdspecs/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.docs/.devcontainer(compose updated for monorepo layout: mounts repo at/workspaces/ruby_ui, working_dirdocs, ports3001: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.http://localhost:3001/docs/toastaftercd docs/.devcontainer && docker compose up -dand verify visually:ruby-ui:toast:dismiss.RubyUI.toast.promise(...)from console → loading → success transition.Alt+Tfocuses first toast;Escapedismisses focused.:top_right,:top_center, etc.) place region correctly.Notes for reviewer
docs/.devcontainer/compose.yamlwas rewired for the monorepo layout (was pointing at the legacy siblingweb/repo). If you use VSCode "Reopen in Container" this should still work.