diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js index b3976c5bd..8b6e2c8f7 100644 --- a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -39,9 +39,13 @@ export default class extends Controller { dir: { type: String, default: "ltr" }, } - connect() { + initialize() { + this._expanded = false this._heights = new Map() this._resizeObservers = new WeakMap() + } + + connect() { this._expanded = this.expandValue this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) this._registerGlobalApi() @@ -59,6 +63,8 @@ export default class extends Controller { this._listEl.addEventListener("pointerenter", this._onPointerEnter) this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) + + this._reflow() } disconnect() { @@ -67,9 +73,21 @@ 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 + } + + positionValueChanged(value) { + this.element.setAttribute("data-position", value) + this._reflow() + } + + expandValueChanged(value) { + this._expanded = value + this._reflow() } toastTargetConnected(el) { + this._resizeObservers.get(el)?.disconnect() if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { this._heights.set(el, el.offsetHeight) @@ -90,13 +108,11 @@ 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 - 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()}` @@ -151,7 +167,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 })) } @@ -243,7 +259,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() } @@ -278,7 +294,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 b3976c5bd..8b6e2c8f7 100644 --- a/gem/lib/ruby_ui/toast/toaster_controller.js +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -39,9 +39,13 @@ export default class extends Controller { dir: { type: String, default: "ltr" }, } - connect() { + initialize() { + this._expanded = false this._heights = new Map() this._resizeObservers = new WeakMap() + } + + connect() { this._expanded = this.expandValue this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) this._registerGlobalApi() @@ -59,6 +63,8 @@ export default class extends Controller { this._listEl.addEventListener("pointerenter", this._onPointerEnter) this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) + + this._reflow() } disconnect() { @@ -67,9 +73,21 @@ 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 + } + + positionValueChanged(value) { + this.element.setAttribute("data-position", value) + this._reflow() + } + + expandValueChanged(value) { + this._expanded = value + this._reflow() } toastTargetConnected(el) { + this._resizeObservers.get(el)?.disconnect() if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { this._heights.set(el, el.offsetHeight) @@ -90,13 +108,11 @@ 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 - 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()}` @@ -151,7 +167,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 })) } @@ -243,7 +259,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() } @@ -278,7 +294,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 4ad7014a6..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 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 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": {