From 89c3ba0b8b8f83f7bb92960e4bccfcc44c9d357e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Wed, 5 Aug 2026 10:19:47 -0300 Subject: [PATCH 1/7] fix(toast): init toaster state in initialize() to survive pre-existing targets Co-Authored-By: Claude Opus 4.8 --- .../controllers/ruby_ui/toaster_controller.js | 14 +++++++++++++- gem/lib/ruby_ui/toast/toaster_controller.js | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index b3976c5bd..cc68c97ed 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -39,9 +39,17 @@ export default class extends Controller { dir: { type: String, default: "ltr" }, } - connect() { + initialize() { + // Initialize state in `initialize` (not `connect`) so `toastTargetConnected` + // can run safely for toasts already present in the DOM (e.g. server-rendered + // flash) before `connect` runs. Stimulus fires `targetConnected` before + // `connect` for pre-existing targets, so any state they touch must exist first. this._heights = new Map() this._resizeObservers = new WeakMap() + this._expanded = false + } + + connect() { this._expanded = this.expandValue this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) this._registerGlobalApi() @@ -59,6 +67,10 @@ export default class extends Controller { this._listEl.addEventListener("pointerenter", this._onPointerEnter) this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) + + // Position toasts already present at connect (server-rendered flash), since + // their `toastTargetConnected` ran before `_listEl` existed and returned early. + this._reflow() } disconnect() { diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index b3976c5bd..cc68c97ed 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -39,9 +39,17 @@ export default class extends Controller { dir: { type: String, default: "ltr" }, } - connect() { + initialize() { + // Initialize state in `initialize` (not `connect`) so `toastTargetConnected` + // can run safely for toasts already present in the DOM (e.g. server-rendered + // flash) before `connect` runs. Stimulus fires `targetConnected` before + // `connect` for pre-existing targets, so any state they touch must exist first. this._heights = new Map() this._resizeObservers = new WeakMap() + this._expanded = false + } + + connect() { this._expanded = this.expandValue this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) this._registerGlobalApi() @@ -59,6 +67,10 @@ export default class extends Controller { this._listEl.addEventListener("pointerenter", this._onPointerEnter) this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) + + // Position toasts already present at connect (server-rendered flash), since + // their `toastTargetConnected` ran before `_listEl` existed and returned early. + this._reflow() } disconnect() { From 39297bc77cd553c1fcf5b4869ae3a2a6e2b4731c Mon Sep 17 00:00:00 2001 From: Jean Pierry Date: Wed, 5 Aug 2026 10:27:28 -0300 Subject: [PATCH 2/7] Update toaster_controller.js Signed-off-by: Jean Pierry --- .../javascript/controllers/ruby_ui/toaster_controller.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index cc68c97ed..dc7c184c7 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -40,13 +40,9 @@ export default class extends Controller { } initialize() { - // Initialize state in `initialize` (not `connect`) so `toastTargetConnected` - // can run safely for toasts already present in the DOM (e.g. server-rendered - // flash) before `connect` runs. Stimulus fires `targetConnected` before - // `connect` for pre-existing targets, so any state they touch must exist first. + this._expanded = false this._heights = new Map() this._resizeObservers = new WeakMap() - this._expanded = false } connect() { @@ -68,8 +64,6 @@ export default class extends Controller { this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) - // Position toasts already present at connect (server-rendered flash), since - // their `toastTargetConnected` ran before `_listEl` existed and returned early. this._reflow() } From e4dc7837c351fce9d8ad44a4fd02af64db7b8736 Mon Sep 17 00:00:00 2001 From: Jean Pierry Date: Wed, 5 Aug 2026 10:37:19 -0300 Subject: [PATCH 3/7] Update toaster_controller.js Signed-off-by: Jean Pierry --- gem/lib/ruby_ui/toast/toaster_controller.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index cc68c97ed..dc7c184c7 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -40,13 +40,9 @@ export default class extends Controller { } initialize() { - // Initialize state in `initialize` (not `connect`) so `toastTargetConnected` - // can run safely for toasts already present in the DOM (e.g. server-rendered - // flash) before `connect` runs. Stimulus fires `targetConnected` before - // `connect` for pre-existing targets, so any state they touch must exist first. + this._expanded = false this._heights = new Map() this._resizeObservers = new WeakMap() - this._expanded = false } connect() { @@ -68,8 +64,6 @@ export default class extends Controller { this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) - // Position toasts already present at connect (server-rendered flash), since - // their `toastTargetConnected` ran before `_listEl` existed and returned early. this._reflow() } From 9179be896e37e42ae63f3bedce930dbd62a894d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Wed, 5 Aug 2026 10:40:50 -0300 Subject: [PATCH 4/7] chore(mcp): rebuild registry.json for toaster_controller fix Co-Authored-By: Claude Opus 4.8 --- mcp/data/registry.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 4ad7014a6..e8a86758b 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -2787,7 +2787,7 @@ }, { "path": "toaster_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n connect() {\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" } ], "dependencies": { From 9ec2b6f4e309f37f47e5adf97f1c8759c8df5cc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Wed, 5 Aug 2026 11:05:10 -0300 Subject: [PATCH 5/7] fix(toast): clear _listEl on disconnect for symmetric teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `connect()` resolves `_listEl`, but `disconnect()` left it pointing at the detached `
    `. Stimulus tears down in this order (Context#disconnect): `controller.disconnect()` first, then `targetObserver.stop()` -> `disconnectAllTargets()`, so every `toastTargetDisconnected` — and the `_reflow()` it triggers — runs *after* the controller disconnected, writing inline styles onto nodes on their way out. Null out `_listEl` at the end of `disconnect()` so `_reflow()`'s existing `if (!this._listEl) return` guard actually does its job. The global API closure (`window.RubyUI.toast`) outlives the controller and calls `_dismissById` / `_mutate` directly rather than through the removed window listeners, so those reads become optional and `_spawn` bails early instead of throwing on a null list. --- .../javascript/controllers/ruby_ui/toaster_controller.js | 8 +++++--- gem/lib/ruby_ui/toast/toaster_controller.js | 8 +++++--- mcp/data/registry.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index dc7c184c7..527baea86 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -73,6 +73,7 @@ export default class extends Controller { this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) document.removeEventListener("keydown", this._onKey) + this._listEl = null } toastTargetConnected(el) { @@ -96,6 +97,7 @@ export default class extends Controller { } _spawn(detail) { + if (!this._listEl) return null const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" const tpl = this._skeletonFor(variant) if (!tpl) return null @@ -157,7 +159,7 @@ export default class extends Controller { ) return } - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) } @@ -249,7 +251,7 @@ export default class extends Controller { if (wantCtrl !== e.ctrlKey) return if (wantMeta !== e.metaKey) return e.preventDefault() - const first = this._listEl.firstElementChild + const first = this._listEl?.firstElementChild first?.focus() } @@ -284,7 +286,7 @@ export default class extends Controller { } _mutate(id, variant, text) { - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) if (!el) return el.dataset.variant = variant el.setAttribute("role", variant === "error" ? "alert" : "status") diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index dc7c184c7..527baea86 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -73,6 +73,7 @@ export default class extends Controller { this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) document.removeEventListener("keydown", this._onKey) + this._listEl = null } toastTargetConnected(el) { @@ -96,6 +97,7 @@ export default class extends Controller { } _spawn(detail) { + if (!this._listEl) return null const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" const tpl = this._skeletonFor(variant) if (!tpl) return null @@ -157,7 +159,7 @@ export default class extends Controller { ) return } - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) } @@ -249,7 +251,7 @@ export default class extends Controller { if (wantCtrl !== e.ctrlKey) return if (wantMeta !== e.metaKey) return e.preventDefault() - const first = this._listEl.firstElementChild + const first = this._listEl?.firstElementChild first?.focus() } @@ -284,7 +286,7 @@ export default class extends Controller { } _mutate(id, variant, text) { - const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + const el = this._listEl?.querySelector(`#${CSS.escape(id)}`) if (!el) return el.dataset.variant = variant el.setAttribute("role", variant === "error" ? "alert" : "status") diff --git a/mcp/data/registry.json b/mcp/data/registry.json index e8a86758b..14aed76bb 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -2787,7 +2787,7 @@ }, { "path": "toaster_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" } ], "dependencies": { From 4dc2769d33f739f2ca511a83de7446644910bacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Wed, 5 Aug 2026 11:06:20 -0300 Subject: [PATCH 6/7] refactor(toast): react to position/expand via value changed callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `position` and `expand` are Stimulus values, but nothing reacted to them changing: `_spawn` mirrored `positionValue` into `data-position` by hand, and `expand` was only read once in `connect()`. Flipping either attribute at runtime (`element.dataset.rubyUiToasterExpandValue = "true"`) had no effect until the next pointer event. Add `positionValueChanged` / `expandValueChanged` so the DOM follows the value: the position callback owns the `data-position` sync the CSS placement rules key off, and both reflow. `_spawn` now just assigns `positionValue`. Both callbacks fire during `valueObserver.start()`, before `connect()`, so they rely on `_reflow()`'s `if (!this._listEl) return` guard — same pattern as the target callbacks. `connect()` keeps `_expanded = this.expandValue`: `StringMapObserver#stop` does not clear its map, so an unchanged attribute fires no callback when the observer restarts. The region is `data-turbo-permanent`, so it really does disconnect/reconnect with the same controller instance, and without that line a toaster left hover-expanded would reconnect still expanded. --- .../controllers/ruby_ui/toaster_controller.js | 15 +++++++++++---- gem/lib/ruby_ui/toast/toaster_controller.js | 15 +++++++++++---- mcp/data/registry.json | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index 527baea86..e883f0c3f 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -76,6 +76,16 @@ export default class extends Controller { this._listEl = null } + positionValueChanged(value) { + this.element.setAttribute("data-position", value) + this._reflow() + } + + expandValueChanged(value) { + this._expanded = value + this._reflow() + } + toastTargetConnected(el) { if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { @@ -101,10 +111,7 @@ export default class extends Controller { const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" const tpl = this._skeletonFor(variant) if (!tpl) return null - if (detail.position) { - this.element.setAttribute("data-position", detail.position) - this.positionValue = detail.position - } + if (detail.position) this.positionValue = detail.position const node = tpl.content.firstElementChild.cloneNode(true) node.id = detail.id || `toast-${this._uuid()}` diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index 527baea86..e883f0c3f 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -76,6 +76,16 @@ export default class extends Controller { this._listEl = null } + positionValueChanged(value) { + this.element.setAttribute("data-position", value) + this._reflow() + } + + expandValueChanged(value) { + this._expanded = value + this._reflow() + } + toastTargetConnected(el) { if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { @@ -101,10 +111,7 @@ export default class extends Controller { const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" const tpl = this._skeletonFor(variant) if (!tpl) return null - if (detail.position) { - this.element.setAttribute("data-position", detail.position) - this.positionValue = detail.position - } + if (detail.position) this.positionValue = detail.position const node = tpl.content.firstElementChild.cloneNode(true) node.id = detail.id || `toast-${this._uuid()}` diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 14aed76bb..5f95c0795 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -2787,7 +2787,7 @@ }, { "path": "toaster_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) {\n this.element.setAttribute(\"data-position\", detail.position)\n this.positionValue = detail.position\n }\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n positionValueChanged(value) {\n this.element.setAttribute(\"data-position\", value)\n this._reflow()\n }\n\n expandValueChanged(value) {\n this._expanded = value\n this._reflow()\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) this.positionValue = detail.position\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" } ], "dependencies": { From e04c2524a55f1e2581dc42a485c3e7c47aaeef23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Djalma=20Ara=C3=BAjo?= Date: Wed, 5 Aug 2026 11:06:39 -0300 Subject: [PATCH 7/7] fix(toast): make toastTargetConnected idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that `_resizeObservers` lives in `initialize()` it survives disconnect/reconnect cycles of the same element, so a second `toastTargetConnected` for an element that still has an observer would overwrite the WeakMap entry and leak the first one — it keeps observing and keeps calling `_reflow()`. Stimulus guards against that today (`TargetObserver#connectTarget` skips elements already in `targetsByName`), but the callback should not depend on that internal: disconnect any existing observer for the element first. --- docs/app/javascript/controllers/ruby_ui/toaster_controller.js | 1 + gem/lib/ruby_ui/toast/toaster_controller.js | 1 + mcp/data/registry.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index e883f0c3f..8b6e2c8f7 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -87,6 +87,7 @@ export default class extends Controller { } toastTargetConnected(el) { + this._resizeObservers.get(el)?.disconnect() if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { this._heights.set(el, el.offsetHeight) diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js index e883f0c3f..8b6e2c8f7 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -87,6 +87,7 @@ export default class extends Controller { } toastTargetConnected(el) { + this._resizeObservers.get(el)?.disconnect() if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { this._heights.set(el, el.offsetHeight) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index 5f95c0795..6281e433e 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -2787,7 +2787,7 @@ }, { "path": "toaster_controller.js", - "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n positionValueChanged(value) {\n this.element.setAttribute(\"data-position\", value)\n this._reflow()\n }\n\n expandValueChanged(value) {\n this._expanded = value\n this._reflow()\n }\n\n toastTargetConnected(el) {\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) this.positionValue = detail.position\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" + "content": "import { Controller } from \"@hotwired/stimulus\"\n\nconst VARIANTS = [\"default\", \"success\", \"error\", \"warning\", \"info\", \"loading\"]\n\nlet streamActionRegistered = false\n\nfunction registerStreamAction() {\n if (streamActionRegistered) return\n if (typeof window === \"undefined\") return\n const Turbo = window.Turbo\n if (!Turbo?.StreamActions) return\n Turbo.StreamActions.toast = function () {\n const detail = {}\n for (const attr of this.attributes) {\n if (attr.name === \"action\" || attr.name === \"target\" || attr.name === \"targets\") continue\n detail[attr.name] = attr.value\n }\n if (detail.duration != null && detail.duration !== \"\") detail.duration = Number(detail.duration)\n if (detail.dismissible != null) detail.dismissible = detail.dismissible !== \"false\"\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", { detail }))\n }\n streamActionRegistered = true\n}\n\n// Connects to data-controller=\"ruby-ui--toaster\"\nexport default class extends Controller {\n static targets = [\"skeleton\", \"toast\", \"actionTpl\", \"cancelTpl\", \"closeTpl\"]\n static values = {\n position: { type: String, default: \"bottom-right\" },\n expand: { type: Boolean, default: false },\n max: { type: Number, default: 3 },\n duration: { type: Number, default: 4000 },\n gap: { type: Number, default: 14 },\n offset: { type: Number, default: 24 },\n theme: { type: String, default: \"system\" },\n richColors: { type: Boolean, default: false },\n closeButton: { type: Boolean, default: false },\n hotkey: { type: String, default: \"alt+t\" },\n dir: { type: String, default: \"ltr\" },\n }\n\n initialize() {\n this._expanded = false\n this._heights = new Map()\n this._resizeObservers = new WeakMap()\n }\n\n connect() {\n this._expanded = this.expandValue\n this._listEl = this.element.querySelector(\"ol\") || (this.element.tagName === \"OL\" ? this.element : null)\n this._registerGlobalApi()\n registerStreamAction()\n if (!this._listEl) return\n\n this._onPointerEnter = () => this._setExpanded(true)\n this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }\n this._onWindowToast = (e) => this._spawn(e.detail || {})\n this._onWindowDismissAll = () => this._dismissById(null)\n this._onKey = this._onKey.bind(this)\n\n window.addEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.addEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl.addEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl.addEventListener(\"pointerleave\", this._onPointerLeave)\n document.addEventListener(\"keydown\", this._onKey)\n\n this._reflow()\n }\n\n disconnect() {\n window.removeEventListener(\"ruby-ui:toast\", this._onWindowToast)\n window.removeEventListener(\"ruby-ui:toast:dismiss-all\", this._onWindowDismissAll)\n this._listEl?.removeEventListener(\"pointerenter\", this._onPointerEnter)\n this._listEl?.removeEventListener(\"pointerleave\", this._onPointerLeave)\n document.removeEventListener(\"keydown\", this._onKey)\n this._listEl = null\n }\n\n positionValueChanged(value) {\n this.element.setAttribute(\"data-position\", value)\n this._reflow()\n }\n\n expandValueChanged(value) {\n this._expanded = value\n this._reflow()\n }\n\n toastTargetConnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this._heights.set(el, el.offsetHeight)\n this._reflow()\n })\n ro.observe(el)\n this._resizeObservers.set(el, ro)\n }\n this._heights.set(el, el.offsetHeight || 64)\n this._reflow()\n }\n\n toastTargetDisconnected(el) {\n this._resizeObservers.get(el)?.disconnect()\n this._resizeObservers.delete(el)\n this._heights.delete(el)\n this._reflow()\n }\n\n _spawn(detail) {\n if (!this._listEl) return null\n const variant = VARIANTS.includes(detail.variant) ? detail.variant : \"default\"\n const tpl = this._skeletonFor(variant)\n if (!tpl) return null\n if (detail.position) this.positionValue = detail.position\n const node = tpl.content.firstElementChild.cloneNode(true)\n\n node.id = detail.id || `toast-${this._uuid()}`\n if (detail.duration != null) {\n const dur = detail.duration === Infinity ? 0 : detail.duration\n node.setAttribute(\"data-ruby-ui--toast-duration-value\", String(dur))\n }\n if (detail.dismissible === false) {\n node.setAttribute(\"data-ruby-ui--toast-dismissible-value\", \"false\")\n }\n if (detail.className) node.className += ` ${detail.className}`\n\n const titleEl = node.querySelector('[data-slot=\"title\"]')\n if (titleEl) titleEl.textContent = detail.title || detail.message || \"\"\n const descEl = node.querySelector('[data-slot=\"description\"]')\n if (descEl) {\n if (detail.description) descEl.textContent = detail.description\n else descEl.remove()\n }\n\n if (detail.action && detail.action.label && this.hasActionTplTarget) {\n const btn = this._cloneSlot(this.actionTplTarget)\n btn.textContent = detail.action.label\n btn.addEventListener(\"click\", (ev) => {\n try { detail.action.onClick?.(ev) } finally {\n node.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n node.appendChild(btn)\n }\n\n if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {\n const btn = this._cloneSlot(this.cancelTplTarget)\n btn.textContent = detail.cancel.label\n node.appendChild(btn)\n }\n\n if (detail.closeButton && this.hasCloseTplTarget) {\n const x = this._cloneSlot(this.closeTplTarget)\n node.classList.add(\"pr-10\")\n node.appendChild(x)\n }\n\n this._listEl.appendChild(node)\n return node.id\n }\n\n _dismissById(id) {\n if (!id) {\n this.toastTargets.forEach((el) =>\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n )\n return\n }\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (el) el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n\n _skeletonFor(variant) {\n return this.skeletonTargets.find((t) => t.dataset.variant === variant)\n }\n\n _cloneSlot(tpl) {\n return tpl.content.firstElementChild.cloneNode(true)\n }\n\n _setExpanded(value) {\n if (this._expanded === value) return\n this._expanded = value\n document.dispatchEvent(new CustomEvent(value ? \"ruby-ui:toast:pause\" : \"ruby-ui:toast:resume\"))\n this._reflow()\n }\n\n _reflow() {\n if (!this._listEl) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const items = this.toastTargets\n const order = isBottom ? items.slice().reverse() : items.slice()\n const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)\n const gap = this.gapValue\n const peekOffset = 16\n const peekScaleStep = 0.05\n const peekOpacityStep = 0.2\n\n const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)\n const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset\n this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`\n\n let acc = 0\n order.forEach((el, i) => {\n const visible = i < this.maxValue\n let yOffset, scale, opacity\n\n if (this._expanded) {\n yOffset = acc + i * gap\n scale = 1\n opacity = visible ? 1 : 0\n } else {\n yOffset = i * peekOffset\n scale = Math.max(0.85, 1 - i * peekScaleStep)\n opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0\n }\n\n const sign = isBottom ? -1 : 1\n const ty = sign * yOffset\n\n el.style.setProperty(\"--opacity\", String(opacity))\n el.style.setProperty(\"--scale\", String(scale))\n el.style.setProperty(\"--y-offset\", `${ty}px`)\n el.style.transformOrigin = isBottom ? \"center bottom\" : \"center top\"\n el.style.top = isBottom ? \"auto\" : \"0\"\n el.style.bottom = isBottom ? \"0\" : \"auto\"\n el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`\n el.style.zIndex = String(1000 - i)\n el.style.pointerEvents = visible ? \"auto\" : \"none\"\n el.tabIndex = visible ? 0 : -1\n\n acc += heights[i] || 0\n })\n\n this._enforceMax(items)\n }\n\n _enforceMax(items) {\n if (items.length <= this.maxValue) return\n const isBottom = this.positionValue.startsWith(\"bottom\")\n const dropping = items.length - this.maxValue\n const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)\n candidates.forEach(el => {\n if (el.dataset.state !== \"closing\") {\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:force-dismiss\", { bubbles: true }))\n }\n })\n }\n\n _onKey(e) {\n const parts = (this.hotkeyValue || \"alt+t\").split(\"+\")\n const key = parts.pop()\n const wantAlt = parts.includes(\"alt\")\n const wantCtrl = parts.includes(\"ctrl\")\n const wantMeta = parts.includes(\"meta\")\n if (e.key.toLowerCase() !== key.toLowerCase()) return\n if (wantAlt !== e.altKey) return\n if (wantCtrl !== e.ctrlKey) return\n if (wantMeta !== e.metaKey) return\n e.preventDefault()\n const first = this._listEl?.firstElementChild\n first?.focus()\n }\n\n _registerGlobalApi() {\n const fire = (variant, message, opts = {}) =>\n window.dispatchEvent(new CustomEvent(\"ruby-ui:toast\", {\n detail: { ...opts, variant, message: opts.title || message }\n }))\n\n const api = (message, opts) => fire(\"default\", message, opts)\n api.success = (m, o) => fire(\"success\", m, o)\n api.error = (m, o) => fire(\"error\", m, o)\n api.warning = (m, o) => fire(\"warning\", m, o)\n api.info = (m, o) => fire(\"info\", m, o)\n api.loading = (m, o = {}) => fire(\"loading\", m, { ...o, duration: o.duration ?? 0 })\n api.dismiss = (id) => {\n if (id) this._dismissById(id)\n else window.dispatchEvent(new CustomEvent(\"ruby-ui:toast:dismiss-all\"))\n }\n api.promise = (p, msgs = {}) => {\n const id = `toast-${this._uuid()}`\n fire(\"loading\", typeof msgs.loading === \"function\" ? msgs.loading() : (msgs.loading || \"Loading...\"), { id, duration: 0 })\n Promise.resolve(p).then(\n (val) => this._mutate(id, \"success\", typeof msgs.success === \"function\" ? msgs.success(val) : msgs.success),\n (err) => this._mutate(id, \"error\", typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error)\n )\n return id\n }\n\n window.RubyUI = window.RubyUI || {}\n window.RubyUI.toast = api\n }\n\n _mutate(id, variant, text) {\n const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)\n if (!el) return\n el.dataset.variant = variant\n el.setAttribute(\"role\", variant === \"error\" ? \"alert\" : \"status\")\n this._swapIcon(el, variant)\n const t = el.querySelector('[data-slot=\"title\"]')\n if (t && text) t.textContent = text\n const dur = String(this.durationValue)\n el.setAttribute(\"data-ruby-ui--toast-duration-value\", dur)\n el.dispatchEvent(new CustomEvent(\"ruby-ui:toast:restart\", { bubbles: true }))\n }\n\n _swapIcon(el, variant) {\n const iconHost = el.querySelector('[data-slot=\"icon\"]')\n if (!iconHost) return\n const tpl = this._skeletonFor(variant)\n if (!tpl) return\n const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot=\"icon\"]')\n iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : \"\"\n }\n\n _uuid() {\n if (typeof crypto !== \"undefined\" && crypto.randomUUID) return crypto.randomUUID()\n return Math.random().toString(36).slice(2) + Date.now().toString(36)\n }\n}\n" } ], "dependencies": {