Skip to content

Commit b537388

Browse files
committed
[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 b668372 commit b537388

3 files changed

Lines changed: 29 additions & 40 deletions

File tree

docs/app/javascript/controllers/ruby_ui/toaster_controller.js

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function registerStreamAction() {
2424

2525
// Connects to data-controller="ruby-ui--toaster"
2626
export default class extends Controller {
27-
static targets = ["skeleton", "toast"]
27+
static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
2828
static values = {
2929
position: { type: String, default: "bottom-right" },
3030
expand: { type: Boolean, default: false },
@@ -117,11 +117,8 @@ export default class extends Controller {
117117
else descEl.remove()
118118
}
119119

120-
if (detail.action && detail.action.label) {
121-
const btn = document.createElement("button")
122-
btn.type = "button"
123-
btn.dataset.slot = "action"
124-
btn.className = "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"
120+
if (detail.action && detail.action.label && this.hasActionTplTarget) {
121+
const btn = this._cloneSlot(this.actionTplTarget)
125122
btn.textContent = detail.action.label
126123
btn.addEventListener("click", (ev) => {
127124
try { detail.action.onClick?.(ev) } finally {
@@ -131,24 +128,14 @@ export default class extends Controller {
131128
node.appendChild(btn)
132129
}
133130

134-
if (detail.cancel && detail.cancel.label) {
135-
const btn = document.createElement("button")
136-
btn.type = "button"
137-
btn.dataset.slot = "cancel"
138-
btn.dataset.action = "click->ruby-ui--toast#dismiss"
139-
btn.className = "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"
131+
if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {
132+
const btn = this._cloneSlot(this.cancelTplTarget)
140133
btn.textContent = detail.cancel.label
141134
node.appendChild(btn)
142135
}
143136

144-
if (detail.closeButton) {
145-
const x = document.createElement("button")
146-
x.type = "button"
147-
x.dataset.slot = "close"
148-
x.dataset.action = "click->ruby-ui--toast#dismiss"
149-
x.setAttribute("aria-label", "Close toast")
150-
x.className = "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"
151-
x.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg><span class="sr-only">Close</span>'
137+
if (detail.closeButton && this.hasCloseTplTarget) {
138+
const x = this._cloneSlot(this.closeTplTarget)
152139
node.classList.add("pr-10")
153140
node.appendChild(x)
154141
}
@@ -172,6 +159,10 @@ export default class extends Controller {
172159
return this.skeletonTargets.find((t) => t.dataset.variant === variant)
173160
}
174161

162+
_cloneSlot(tpl) {
163+
return tpl.content.firstElementChild.cloneNode(true)
164+
}
165+
175166
_setExpanded(value) {
176167
if (this._expanded === value) return
177168
this._expanded = value

gem/lib/ruby_ui/toast/toast_region.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ def view_template(&block)
4141
yield(self) if block
4242
end
4343
SKELETON_VARIANTS.each { |v| skeleton(v) }
44+
slot_template("actionTpl") { render RubyUI::ToastAction.new(label: "") }
45+
slot_template("cancelTpl") { render RubyUI::ToastCancel.new(label: "") }
46+
slot_template("closeTpl") { render RubyUI::ToastClose.new }
4447
end
4548
end
4649

@@ -75,6 +78,10 @@ def skeleton(variant)
7578
end
7679
end
7780

81+
def slot_template(target_name, &)
82+
template(data: {ruby_ui__toaster_target: target_name}, &)
83+
end
84+
7885
def default_attrs
7986
{
8087
id: "ruby-ui-toaster-region",

gem/lib/ruby_ui/toast/toaster_controller.js

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function registerStreamAction() {
2424

2525
// Connects to data-controller="ruby-ui--toaster"
2626
export default class extends Controller {
27-
static targets = ["skeleton", "toast"]
27+
static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
2828
static values = {
2929
position: { type: String, default: "bottom-right" },
3030
expand: { type: Boolean, default: false },
@@ -117,11 +117,8 @@ export default class extends Controller {
117117
else descEl.remove()
118118
}
119119

120-
if (detail.action && detail.action.label) {
121-
const btn = document.createElement("button")
122-
btn.type = "button"
123-
btn.dataset.slot = "action"
124-
btn.className = "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"
120+
if (detail.action && detail.action.label && this.hasActionTplTarget) {
121+
const btn = this._cloneSlot(this.actionTplTarget)
125122
btn.textContent = detail.action.label
126123
btn.addEventListener("click", (ev) => {
127124
try { detail.action.onClick?.(ev) } finally {
@@ -131,24 +128,14 @@ export default class extends Controller {
131128
node.appendChild(btn)
132129
}
133130

134-
if (detail.cancel && detail.cancel.label) {
135-
const btn = document.createElement("button")
136-
btn.type = "button"
137-
btn.dataset.slot = "cancel"
138-
btn.dataset.action = "click->ruby-ui--toast#dismiss"
139-
btn.className = "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"
131+
if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {
132+
const btn = this._cloneSlot(this.cancelTplTarget)
140133
btn.textContent = detail.cancel.label
141134
node.appendChild(btn)
142135
}
143136

144-
if (detail.closeButton) {
145-
const x = document.createElement("button")
146-
x.type = "button"
147-
x.dataset.slot = "close"
148-
x.dataset.action = "click->ruby-ui--toast#dismiss"
149-
x.setAttribute("aria-label", "Close toast")
150-
x.className = "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"
151-
x.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg><span class="sr-only">Close</span>'
137+
if (detail.closeButton && this.hasCloseTplTarget) {
138+
const x = this._cloneSlot(this.closeTplTarget)
152139
node.classList.add("pr-10")
153140
node.appendChild(x)
154141
}
@@ -172,6 +159,10 @@ export default class extends Controller {
172159
return this.skeletonTargets.find((t) => t.dataset.variant === variant)
173160
}
174161

162+
_cloneSlot(tpl) {
163+
return tpl.content.firstElementChild.cloneNode(true)
164+
}
165+
175166
_setExpanded(value) {
176167
if (this._expanded === value) return
177168
this._expanded = value

0 commit comments

Comments
 (0)