diff --git a/docs/.devcontainer/compose.yaml b/docs/.devcontainer/compose.yaml index b891aefe2..b806b94a4 100644 --- a/docs/.devcontainer/compose.yaml +++ b/docs/.devcontainer/compose.yaml @@ -7,7 +7,10 @@ services: dockerfile: .devcontainer/Dockerfile volumes: - - ../../web:/workspaces/web:cached + - ../..:/workspaces/ruby_ui:cached + working_dir: /workspaces/ruby_ui/docs + ports: + - "3001:3000" # Overrides default command so things don't shut down after the process ends. command: sleep infinity diff --git a/docs/.gitignore b/docs/.gitignore index 011574f2a..401f00c63 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -6,6 +6,7 @@ # Ignore bundler config. /.bundle +/vendor/bundle # Ignore all logfiles and tempfiles. /log/* diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index 9f2775c1b..fbff68640 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -50,6 +50,7 @@ def components {name: "Tabs", path: docs_tabs_path}, {name: "Textarea", path: docs_textarea_path}, {name: "Theme Toggle", path: docs_theme_toggle_path}, + {name: "Toast", path: docs_toast_path}, {name: "Tooltip", path: docs_tooltip_path}, {name: "Typography", path: docs_typography_path} ] diff --git a/docs/app/controllers/docs/toast_demo_controller.rb b/docs/app/controllers/docs/toast_demo_controller.rb new file mode 100644 index 000000000..be1b73b23 --- /dev/null +++ b/docs/app/controllers/docs/toast_demo_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Docs + class ToastDemoController < ApplicationController + def default = push(:default, "Event scheduled", "Friday at 3:00 PM") + + def success = push(:success, "Saved successfully", "Your changes are live.") + + def error = push(:error, "Something went wrong", "Please retry.") + + def warning = push(:warning, "Heads up", "Storage almost full.") + + def info = push(:info, "FYI", "New version available.") + + def with_action + render turbo_stream: build_stream(:default, "Email archived", nil, action_label: "Undo") + end + + private + + def push(variant, title, description) + render turbo_stream: build_stream(variant, title, description) + end + + def build_stream(variant, title, description, action_label: nil) + content = ToastFragment.new( + variant: variant, + title: title, + description: description, + action_label: action_label + ).call + turbo_stream.append("ruby-ui-toaster", content.html_safe) + end + + class ToastFragment < Phlex::HTML + def initialize(variant:, title:, description:, action_label: nil) + @variant = variant + @title = title + @description = description + @action_label = action_label + end + + def view_template + render RubyUI::ToastItem.new(variant: @variant) do + render RubyUI::ToastIcon.new(variant: @variant) + div(class: "flex flex-col gap-1 flex-1 min-w-0") do + render RubyUI::ToastTitle.new { @title } + render(RubyUI::ToastDescription.new { @description }) if @description + end + if @action_label + render RubyUI::ToastAction.new(label: @action_label, on: "click->ruby-ui--toast#dismiss") + end + render RubyUI::ToastClose.new + end + end + end + end +end diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index f60ca1237..904d00ad2 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -222,6 +222,10 @@ def theme_toggle render Views::Docs::ThemeToggle.new end + def toast + render Views::Docs::Toast.new + end + def tooltip render Views::Docs::Tooltip.new end diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index e68815bd9..9954a6f53 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -7,6 +7,9 @@ import { application } from "./application" import IframeThemeController from "./iframe_theme_controller" application.register("iframe-theme", IframeThemeController) +import ToastDemoController from "./toast_demo_controller" +application.register("toast-demo", ToastDemoController) + import RubyUi__AccordionController from "./ruby_ui/accordion_controller" application.register("ruby-ui--accordion", RubyUi__AccordionController) @@ -91,6 +94,12 @@ application.register("ruby-ui--tabs", RubyUi__TabsController) import RubyUi__ThemeToggleController from "./ruby_ui/theme_toggle_controller" application.register("ruby-ui--theme-toggle", RubyUi__ThemeToggleController) +import RubyUi__ToastController from "./ruby_ui/toast_controller" +application.register("ruby-ui--toast", RubyUi__ToastController) + +import RubyUi__ToasterController from "./ruby_ui/toaster_controller" +application.register("ruby-ui--toaster", RubyUi__ToasterController) + import RubyUi__TooltipController from "./ruby_ui/tooltip_controller" application.register("ruby-ui--tooltip", RubyUi__TooltipController) diff --git a/docs/app/javascript/controllers/ruby_ui/toast_controller.js b/docs/app/javascript/controllers/ruby_ui/toast_controller.js new file mode 100644 index 000000000..99010a8fe --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toast_controller.js @@ -0,0 +1,151 @@ +import { Controller } from "@hotwired/stimulus" + +const SWIPE_THRESHOLD = 45 +const TIME_BEFORE_UNMOUNT = 200 + +// Connects to data-controller="ruby-ui--toast" +export default class extends Controller { + static values = { + duration: { type: Number, default: 4000 }, + dismissible: { type: Boolean, default: true }, + invert: { type: Boolean, default: false }, + onDismiss: String, + onAutoClose: String, + } + + connect() { + this._timer = null + this._startedAt = 0 + this._remaining = this.durationValue + this._paused = false + this._swipe = { active: false, x: 0, y: 0, startedAt: 0 } + + this._onPointerDown = this._onPointerDown.bind(this) + this._onPointerMove = this._onPointerMove.bind(this) + this._onPointerUp = this._onPointerUp.bind(this) + this._onPointerEnter = () => this._pause() + this._onPointerLeave = () => { if (!this._swipe.active) this._resume() } + this._onKeyDown = this._onKeyDown.bind(this) + this._onForceDismiss = (e) => { e.stopPropagation(); this._close() } + this._onRestart = () => this._restart() + this._onRegionPause = () => this._pause() + this._onRegionResume = () => this._resume() + + this.element.addEventListener("pointerdown", this._onPointerDown) + this.element.addEventListener("pointerenter", this._onPointerEnter) + this.element.addEventListener("pointerleave", this._onPointerLeave) + this.element.addEventListener("keydown", this._onKeyDown) + this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) + this.element.addEventListener("ruby-ui:toast:restart", this._onRestart) + document.addEventListener("ruby-ui:toast:pause", this._onRegionPause) + document.addEventListener("ruby-ui:toast:resume", this._onRegionResume) + + requestAnimationFrame(() => { + this.element.dataset.state = "open" + this._start() + }) + } + + disconnect() { + this._clearTimer() + this.element.removeEventListener("pointerdown", this._onPointerDown) + this.element.removeEventListener("pointerenter", this._onPointerEnter) + this.element.removeEventListener("pointerleave", this._onPointerLeave) + this.element.removeEventListener("keydown", this._onKeyDown) + this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) + this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart) + document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause) + document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume) + } + + dismiss(e) { + e?.preventDefault() + if (!this.dismissibleValue) return + this._close("dismiss") + } + + _close(reason) { + if (this.element.dataset.state === "closing") return + this.element.dataset.state = "closing" + this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } })) + setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT) + } + + _start() { + if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return + this._startedAt = performance.now() + this._remaining = this.durationValue + this._timer = setTimeout(() => this._close("auto"), this._remaining) + } + + _restart() { + this._clearTimer() + this._start() + } + + _pause() { + if (this._paused || !this._timer) return + this._paused = true + clearTimeout(this._timer) + this._timer = null + this._remaining -= performance.now() - this._startedAt + } + + _resume() { + if (!this._paused) return + this._paused = false + if (this._remaining <= 0) return this._close("auto") + this._startedAt = performance.now() + this._timer = setTimeout(() => this._close("auto"), this._remaining) + } + + _clearTimer() { + if (this._timer) clearTimeout(this._timer) + this._timer = null + } + + _onKeyDown(e) { + if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e) + } + + _onPointerDown(e) { + if (!this.dismissibleValue) return + if (e.target.closest("button")) return + try { this.element.setPointerCapture(e.pointerId) } catch {} + this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId } + this.element.dataset.swipe = "start" + this.element.addEventListener("pointermove", this._onPointerMove) + this.element.addEventListener("pointerup", this._onPointerUp) + this.element.addEventListener("pointercancel", this._onPointerUp) + } + + _onPointerMove(e) { + const dx = e.clientX - this._swipe.x + const dy = e.clientY - this._swipe.y + this.element.dataset.swipe = "move" + this.element.style.transform = `translate(${dx}px, ${dy}px)` + } + + _onPointerUp(e) { + const dx = e.clientX - this._swipe.x + const dy = e.clientY - this._swipe.y + const dist = Math.hypot(dx, dy) + const dt = performance.now() - this._swipe.startedAt + const velocity = dist / Math.max(dt, 1) + this.element.removeEventListener("pointermove", this._onPointerMove) + this.element.removeEventListener("pointerup", this._onPointerUp) + this.element.removeEventListener("pointercancel", this._onPointerUp) + this._swipe.active = false + if (dist > SWIPE_THRESHOLD || velocity > 0.5) { + this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`) + this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`) + this.element.dataset.swipe = "end" + this.element.style.transform = "" + this._close("dismiss") + } else { + this.element.dataset.swipe = "cancel" + this.element.style.transform = "" + this._resume() + } + } +} diff --git a/docs/app/javascript/controllers/ruby_ui/toaster_controller.js b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js new file mode 100644 index 000000000..b3976c5bd --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/toaster_controller.js @@ -0,0 +1,306 @@ +import { Controller } from "@hotwired/stimulus" + +const VARIANTS = ["default", "success", "error", "warning", "info", "loading"] + +let streamActionRegistered = false + +function registerStreamAction() { + if (streamActionRegistered) return + if (typeof window === "undefined") return + const Turbo = window.Turbo + if (!Turbo?.StreamActions) return + Turbo.StreamActions.toast = function () { + const detail = {} + for (const attr of this.attributes) { + if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue + detail[attr.name] = attr.value + } + if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration) + if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false" + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail })) + } + streamActionRegistered = true +} + +// Connects to data-controller="ruby-ui--toaster" +export default class extends Controller { + static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"] + static values = { + position: { type: String, default: "bottom-right" }, + expand: { type: Boolean, default: false }, + max: { type: Number, default: 3 }, + duration: { type: Number, default: 4000 }, + gap: { type: Number, default: 14 }, + offset: { type: Number, default: 24 }, + theme: { type: String, default: "system" }, + richColors: { type: Boolean, default: false }, + closeButton: { type: Boolean, default: false }, + hotkey: { type: String, default: "alt+t" }, + dir: { type: String, default: "ltr" }, + } + + connect() { + this._heights = new Map() + this._resizeObservers = new WeakMap() + this._expanded = this.expandValue + this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) + this._registerGlobalApi() + registerStreamAction() + if (!this._listEl) return + + this._onPointerEnter = () => this._setExpanded(true) + this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) } + this._onWindowToast = (e) => this._spawn(e.detail || {}) + this._onWindowDismissAll = () => this._dismissById(null) + this._onKey = this._onKey.bind(this) + + window.addEventListener("ruby-ui:toast", this._onWindowToast) + window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) + this._listEl.addEventListener("pointerenter", this._onPointerEnter) + this._listEl.addEventListener("pointerleave", this._onPointerLeave) + document.addEventListener("keydown", this._onKey) + } + + disconnect() { + window.removeEventListener("ruby-ui:toast", this._onWindowToast) + window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) + this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) + this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) + document.removeEventListener("keydown", this._onKey) + } + + toastTargetConnected(el) { + if (typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => { + this._heights.set(el, el.offsetHeight) + this._reflow() + }) + ro.observe(el) + this._resizeObservers.set(el, ro) + } + this._heights.set(el, el.offsetHeight || 64) + this._reflow() + } + + toastTargetDisconnected(el) { + this._resizeObservers.get(el)?.disconnect() + this._resizeObservers.delete(el) + this._heights.delete(el) + this._reflow() + } + + _spawn(detail) { + 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 + } + const node = tpl.content.firstElementChild.cloneNode(true) + + node.id = detail.id || `toast-${this._uuid()}` + if (detail.duration != null) { + const dur = detail.duration === Infinity ? 0 : detail.duration + node.setAttribute("data-ruby-ui--toast-duration-value", String(dur)) + } + if (detail.dismissible === false) { + node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") + } + if (detail.className) node.className += ` ${detail.className}` + + const titleEl = node.querySelector('[data-slot="title"]') + if (titleEl) titleEl.textContent = detail.title || detail.message || "" + const descEl = node.querySelector('[data-slot="description"]') + if (descEl) { + if (detail.description) descEl.textContent = detail.description + else descEl.remove() + } + + if (detail.action && detail.action.label && this.hasActionTplTarget) { + const btn = this._cloneSlot(this.actionTplTarget) + btn.textContent = detail.action.label + btn.addEventListener("click", (ev) => { + try { detail.action.onClick?.(ev) } finally { + node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + }) + node.appendChild(btn) + } + + if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) { + const btn = this._cloneSlot(this.cancelTplTarget) + btn.textContent = detail.cancel.label + node.appendChild(btn) + } + + if (detail.closeButton && this.hasCloseTplTarget) { + const x = this._cloneSlot(this.closeTplTarget) + node.classList.add("pr-10") + node.appendChild(x) + } + + this._listEl.appendChild(node) + return node.id + } + + _dismissById(id) { + if (!id) { + this.toastTargets.forEach((el) => + el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + ) + return + } + const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + + _skeletonFor(variant) { + return this.skeletonTargets.find((t) => t.dataset.variant === variant) + } + + _cloneSlot(tpl) { + return tpl.content.firstElementChild.cloneNode(true) + } + + _setExpanded(value) { + if (this._expanded === value) return + this._expanded = value + document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume")) + this._reflow() + } + + _reflow() { + if (!this._listEl) return + const isBottom = this.positionValue.startsWith("bottom") + const items = this.toastTargets + const order = isBottom ? items.slice().reverse() : items.slice() + const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64) + const gap = this.gapValue + const peekOffset = 16 + const peekScaleStep = 0.05 + const peekOpacityStep = 0.2 + + const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1) + const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset + this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px` + + let acc = 0 + order.forEach((el, i) => { + const visible = i < this.maxValue + let yOffset, scale, opacity + + if (this._expanded) { + yOffset = acc + i * gap + scale = 1 + opacity = visible ? 1 : 0 + } else { + yOffset = i * peekOffset + scale = Math.max(0.85, 1 - i * peekScaleStep) + opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0 + } + + const sign = isBottom ? -1 : 1 + const ty = sign * yOffset + + el.style.setProperty("--opacity", String(opacity)) + el.style.setProperty("--scale", String(scale)) + el.style.setProperty("--y-offset", `${ty}px`) + el.style.transformOrigin = isBottom ? "center bottom" : "center top" + el.style.top = isBottom ? "auto" : "0" + el.style.bottom = isBottom ? "0" : "auto" + el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})` + el.style.zIndex = String(1000 - i) + el.style.pointerEvents = visible ? "auto" : "none" + el.tabIndex = visible ? 0 : -1 + + acc += heights[i] || 0 + }) + + this._enforceMax(items) + } + + _enforceMax(items) { + if (items.length <= this.maxValue) return + const isBottom = this.positionValue.startsWith("bottom") + const dropping = items.length - this.maxValue + const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping) + candidates.forEach(el => { + if (el.dataset.state !== "closing") { + el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + }) + } + + _onKey(e) { + const parts = (this.hotkeyValue || "alt+t").split("+") + const key = parts.pop() + const wantAlt = parts.includes("alt") + const wantCtrl = parts.includes("ctrl") + const wantMeta = parts.includes("meta") + if (e.key.toLowerCase() !== key.toLowerCase()) return + if (wantAlt !== e.altKey) return + if (wantCtrl !== e.ctrlKey) return + if (wantMeta !== e.metaKey) return + e.preventDefault() + const first = this._listEl.firstElementChild + first?.focus() + } + + _registerGlobalApi() { + const fire = (variant, message, opts = {}) => + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { + detail: { ...opts, variant, message: opts.title || message } + })) + + const api = (message, opts) => fire("default", message, opts) + api.success = (m, o) => fire("success", m, o) + api.error = (m, o) => fire("error", m, o) + api.warning = (m, o) => fire("warning", m, o) + api.info = (m, o) => fire("info", m, o) + api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 }) + api.dismiss = (id) => { + if (id) this._dismissById(id) + else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all")) + } + api.promise = (p, msgs = {}) => { + const id = `toast-${this._uuid()}` + fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 }) + Promise.resolve(p).then( + (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success), + (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error) + ) + return id + } + + window.RubyUI = window.RubyUI || {} + window.RubyUI.toast = api + } + + _mutate(id, variant, text) { + const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + if (!el) return + el.dataset.variant = variant + el.setAttribute("role", variant === "error" ? "alert" : "status") + this._swapIcon(el, variant) + const t = el.querySelector('[data-slot="title"]') + if (t && text) t.textContent = text + const dur = String(this.durationValue) + el.setAttribute("data-ruby-ui--toast-duration-value", dur) + el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true })) + } + + _swapIcon(el, variant) { + const iconHost = el.querySelector('[data-slot="icon"]') + if (!iconHost) return + const tpl = this._skeletonFor(variant) + if (!tpl) return + const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]') + iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : "" + } + + _uuid() { + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID() + return Math.random().toString(36).slice(2) + Date.now().toString(36) + } +} diff --git a/docs/app/javascript/controllers/toast_demo_controller.js b/docs/app/javascript/controllers/toast_demo_controller.js new file mode 100644 index 000000000..d85d8dd5d --- /dev/null +++ b/docs/app/javascript/controllers/toast_demo_controller.js @@ -0,0 +1,58 @@ +import { Controller } from "@hotwired/stimulus" + +const PRESETS = { + default: { variant: "default", title: "Event has been created", description: "Sunday, December 03, 2023 at 9:00 AM" }, + success: { variant: "success", title: "Event has been created" }, + info: { variant: "info", title: "Be at the area 10 minutes before the event time" }, + warning: { variant: "warning", title: "Event start time cannot be earlier than 8am" }, + error: { variant: "error", title: "Event has not been created" }, + with_action: { variant: "default", title: "Event has been created", action: { label: "Undo" } }, + text_only: { variant: "default", title: "Event has been created" }, + close_button: { variant: "default", title: "Event has been created", description: "Close it manually with the X.", closeButton: true }, + close_action: { variant: "default", title: "Event has been created", description: "Friday at 3:00 PM", closeButton: true, action: { label: "Undo" } }, +} + +export default class extends Controller { + fire(e) { + const kind = e.params.kind || "default" + const t = window.RubyUI?.toast + if (!t) return + + if (kind === "promise") { + t.promise( + new Promise((r) => setTimeout(() => r({ name: "Sonner" }), 1500)), + { + loading: "Loading...", + success: (data) => `${data.name} toast has been added`, + error: "Error", + } + ) + return + } + + const preset = PRESETS[kind] || PRESETS.default + const opts = { + description: preset.description, + action: preset.action, + closeButton: preset.closeButton, + } + const fn = t[preset.variant] || t + fn(preset.title, opts) + } + + position(e) { + const position = e.params.position || "bottom-right" + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { + detail: { + variant: "default", + title: `Position: ${position}`, + description: "Toast spawned in this corner", + position, + } + })) + } + + dismissAll() { + window.RubyUI?.toast.dismiss() + } +} diff --git a/docs/app/views/docs/toast.rb b/docs/app/views/docs/toast.rb new file mode 100644 index 000000000..cc548ade1 --- /dev/null +++ b/docs/app/views/docs/toast.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +class Views::Docs::Toast < Views::Base + include Phlex::Rails::Helpers::ButtonTo + + EXAMPLES = [ + {key: "default", label: "Default", title: "Event has been created", description: "Sunday, December 03, 2023 at 9:00 AM"}, + {key: "success", label: "Success", title: "Event has been created"}, + {key: "info", label: "Info", title: "Be at the area 10 minutes before the event time"}, + {key: "warning", label: "Warning", title: "Event start time cannot be earlier than 8am"}, + {key: "error", label: "Error", title: "Event has not been created"}, + {key: "with_action", label: "With Action", title: "Event has been created", action_label: "Undo"}, + {key: "promise", label: "Promise", title: nil}, + {key: "text_only", label: "Text Only", title: "Event has been created"}, + {key: "close_button", label: "Close Button", title: "Event has been created"}, + {key: "close_action", label: "Close + Action", title: "Event has been created"} + ].freeze + + POSITIONS = %w[top-left top-center top-right bottom-left bottom-center bottom-right].freeze + + def view_template + component = "Toast" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "Toast", + description: "An opinionated toast component." + ) + + Heading(level: 2) { "Examples" } + Heading(level: 3) { "Types" } + render Docs::VisualCodeExample.new(title: "Click any to spawn a toast.", context: self) do + <<~RUBY + div(class: "grid gap-4 sm:grid-cols-2", data: {controller: "toast-demo"}) do + [ + ["default", "Default"], + ["success", "Success"], + ["info", "Info"], + ["warning", "Warning"], + ["error", "Error"], + ["with_action", "With Action"], + ["promise", "Promise"], + ["text_only", "Text Only"], + ["close_button", "Close Button"], + ["close_action", "Close + Action"] + ].each do |key, label| + div(class: "rounded-md border p-6 flex items-center justify-center min-h-[100px]") do + button( + type: "button", + class: "inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent transition-colors cursor-pointer", + data: {action: "click->toast-demo#fire", toast_demo_kind_param: key} + ) { "Show \#{label} toast" } + end + end + end + RUBY + end + + Heading(level: 2) { "About" } + p(class: "text-muted-foreground text-sm leading-relaxed") do + plain "Trigger toasts from the server with Turbo Streams or from JavaScript via " + code(class: "rounded bg-muted px-1.5 py-0.5 text-xs") { "window.RubyUI.toast.*" } + plain ". Heavily inspired by the original " + a( + href: "https://github.com/emilkowalski/sonner", + target: "_blank", + rel: "noopener", + class: "underline underline-offset-2 hover:text-foreground" + ) { "sonner" } + plain "." + end + + Heading(level: 2) { "Mount" } + div(class: "rounded-md border bg-muted/30 p-4") do + Codeblock(<<~RUBY, syntax: :ruby) + # In application_layout.rb (Phlex), once globally: + render RubyUI::ToastRegion.new + + # Pass flash to render Rails flash on initial load: + render RubyUI::ToastRegion.new(flash: helpers.flash.to_h) + RUBY + end + + Heading(level: 3) { "Position" } + p(class: "text-muted-foreground text-sm") { "Use the position prop to change where toasts mount." } + div(class: "rounded-md border p-8 flex flex-wrap items-center justify-center gap-2", data: {controller: "toast-demo"}) do + POSITIONS.each do |pos| + button( + type: "button", + class: button_class, + data: {action: "click->toast-demo#position", toast_demo_position_param: pos} + ) { pos.split("-").map(&:capitalize).join(" ") } + end + end + + Heading(level: 2) { "Server-pushed (Turbo Stream)" } + p(class: "text-muted-foreground text-sm") { "Append a toast to the global region from any controller." } + div(class: "flex flex-wrap gap-2 mt-2") do + button_to "Push success from server", + docs_toast_demo_success_path, + class: button_class, + form: {data: {turbo_stream: "true"}, class: "inline"} + end + div(class: "rounded-md border bg-muted/30 p-4 mt-4") do + Codeblock(<<~RUBY, syntax: :ruby) + # Option A — custom Turbo Stream action (compact): + render turbo_stream: turbo_stream.action( + :toast, + target: "ruby-ui-toaster", + variant: :success, + title: "Saved", + description: "Project updated." + ) + + # Option B — append a fully-rendered ToastItem (for Action / Cancel slots): + render turbo_stream: turbo_stream.append("ruby-ui-toaster") { + render RubyUI::ToastItem.new(variant: :success) do + render RubyUI::ToastIcon.new(variant: :success) + render RubyUI::ToastTitle.new { "Saved" } + render RubyUI::ToastDescription.new { "Project updated." } + end + } + RUBY + end + + Heading(level: 2) { "JavaScript API" } + p(class: "text-muted-foreground text-sm") do + plain "Hotwire-friendly: window.RubyUI.toast.* is sugar over a CustomEvent dispatch. Either path works." + end + div(class: "rounded-md border bg-muted/30 p-4 mt-2") do + Codeblock(<<~JS, syntax: :javascript) + // Sugar: + RubyUI.toast.success("Saved", { description: "Project updated." }) + RubyUI.toast.error("Boom") + RubyUI.toast.info("Heads up") + RubyUI.toast.warning("Storage almost full") + RubyUI.toast.loading("Working...") + RubyUI.toast.dismiss(id) // no-arg: dismiss all + RubyUI.toast.promise(p, { loading, success, error }) + + // Equivalent CustomEvent (any source can dispatch this): + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { + detail: { variant: "success", title: "Saved", description: "..." } + })) + JS + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + render Docs::ComponentsTable.new(component_files(component)) + + Heading(level: 2) { "API Reference" } + + Heading(level: 3) { "Toaster (Region)" } + props_table(REGION_PROPS) + + Heading(level: 3) { "ToastItem" } + props_table(ITEM_PROPS) + + Heading(level: 3) { "JS API options" } + p(class: "text-muted-foreground text-sm") do + plain "Second argument to " + code(class: "rounded bg-muted px-1.5 py-0.5 text-xs") { "RubyUI.toast.(message, options)" } + plain " or " + code(class: "rounded bg-muted px-1.5 py-0.5 text-xs") { "ruby-ui:toast" } + plain " CustomEvent detail." + end + props_table(JS_OPTIONS) + end + end + + REGION_PROPS = [ + {name: "position", default: ":bottom_right", values: ":top_left | :top_center | :top_right | :bottom_left | :bottom_center | :bottom_right", description: "Where the toaster mounts on the viewport."}, + {name: "expand", default: "false", values: "Boolean", description: "Always show items expanded (no stack peek)."}, + {name: "max", default: "3", values: "Integer", description: "Max visible toasts before oldest auto-evicts."}, + {name: "duration", default: "4000", values: "Integer (ms)", description: "Default lifetime per toast. Pass 0 or Infinity to disable auto-dismiss."}, + {name: "gap", default: "14", values: "Integer (px)", description: "Spacing between toasts when expanded."}, + {name: "offset", default: "24", values: "Integer (px)", description: "Distance from the viewport edge."}, + {name: "theme", default: ":system", values: ":system | :light | :dark", description: "Color scheme override."}, + {name: "rich_colors", default: "false", values: "Boolean", description: "Enable variant-tinted backgrounds."}, + {name: "close_button", default: "false", values: "Boolean", description: "Render an X button in every toast (top-right)."}, + {name: "hotkey", default: "%w[alt t]", values: "Array", description: "Keyboard shortcut to focus the first toast."}, + {name: "dir", default: ":ltr", values: ":ltr | :rtl", description: "Text direction."}, + {name: "flash", default: "nil", values: "Hash | nil", description: "Pass `helpers.flash.to_h` to render Rails flash on initial load."} + ].freeze + + ITEM_PROPS = [ + {name: "variant", default: ":default", values: ":default | :success | :error | :warning | :info | :loading", description: "Visual + a11y role + icon."}, + {name: "id", default: "nil", values: "String", description: "DOM id; auto-generated when not provided."}, + {name: "duration", default: "nil", values: "Integer | nil", description: "Override the Region default. nil inherits."}, + {name: "dismissible", default: "true", values: "Boolean", description: "Allow Escape, swipe, X, and force-dismiss to close."}, + {name: "invert", default: "false", values: "Boolean", description: "Invert background/foreground (light-on-dark in light theme)."}, + {name: "on_dismiss", default: "nil", values: "String", description: "Stimulus action descriptor fired when the user dismisses."}, + {name: "on_auto_close", default: "nil", values: "String", description: "Stimulus action descriptor fired when the timer expires."} + ].freeze + + JS_OPTIONS = [ + {name: "title", default: "—", values: "String", description: "Headline text. Falls back to the first positional argument."}, + {name: "description", default: "—", values: "String", description: "Secondary line under the title."}, + {name: "duration", default: "(Region default)", values: "Number | Infinity", description: "ms before auto-dismiss. Infinity = sticky."}, + {name: "action", default: "—", values: "{ label, onClick }", description: "Primary action button rendered inside the toast."}, + {name: "cancel", default: "—", values: "{ label, onClick }", description: "Secondary dismiss button."}, + {name: "closeButton", default: "false", values: "Boolean", description: "Force an X close button on this toast."}, + {name: "position", default: "(Region default)", values: "String", description: "Per-toast position override (changes Region's data-position before append)."}, + {name: "id", default: "(auto)", values: "String", description: "Set a stable id (used by .dismiss(id) and .promise)."}, + {name: "dismissible", default: "true", values: "Boolean", description: "Disable Escape / swipe / dismiss-all for this toast."}, + {name: "className", default: "—", values: "String", description: "Extra classes appended to the rendered
  • ."} + ].freeze + + private + + def props_table(rows) + div(class: "border rounded-lg overflow-hidden") do + Table do + TableHeader do + TableRow do + TableHead { "Prop" } + TableHead { "Default" } + TableHead { "Values" } + TableHead(class: "w-full") { "Description" } + end + end + TableBody do + rows.each do |r| + TableRow do + TableCell { InlineCode { r[:name] } } + TableCell { InlineCode { r[:default] } } + TableCell(class: "whitespace-normal") { InlineCode { r[:values] } } + TableCell(class: "text-muted-foreground") { r[:description] } + end + end + end + end + end + end + + private + + def example_box(ex) + div(class: "rounded-md border p-8 flex items-center justify-center min-h-[120px]") do + button( + type: "button", + class: button_class, + data: {action: "click->toast-demo#fire", toast_demo_kind_param: ex[:key]} + ) { "Show #{ex[:label]} toast" } + end + end + + def button_class + "inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent transition-colors" + end +end diff --git a/docs/app/views/layouts/application_layout.rb b/docs/app/views/layouts/application_layout.rb index 252d2ec8c..80b8e11ca 100644 --- a/docs/app/views/layouts/application_layout.rb +++ b/docs/app/views/layouts/application_layout.rb @@ -17,6 +17,7 @@ def view_template(&block) main(&block) render Shared::Footer.new render Shared::Flashes.new(notice: flash[:notice], alert: flash[:alert]) + render RubyUI::ToastRegion.new end end end diff --git a/docs/app/views/layouts/docs_layout.rb b/docs/app/views/layouts/docs_layout.rb index 70c0ffb01..98a72f745 100644 --- a/docs/app/views/layouts/docs_layout.rb +++ b/docs/app/views/layouts/docs_layout.rb @@ -23,6 +23,7 @@ def view_template(&block) end render Shared::Footer.new render Shared::Flashes.new(notice: flash[:notice], alert: flash[:alert]) + render RubyUI::ToastRegion.new end end end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 71d5ed584..bc53d2a3e 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -66,6 +66,13 @@ get "tabs", to: "docs#tabs", as: :docs_tabs get "textarea", to: "docs#textarea", as: :docs_textarea get "theme_toggle", to: "docs#theme_toggle", as: :docs_theme_toggle + get "toast", to: "docs#toast", as: :docs_toast + post "toast_demo/default", to: "docs/toast_demo#default", as: :docs_toast_demo_default + post "toast_demo/success", to: "docs/toast_demo#success", as: :docs_toast_demo_success + post "toast_demo/error", to: "docs/toast_demo#error", as: :docs_toast_demo_error + post "toast_demo/warning", to: "docs/toast_demo#warning", as: :docs_toast_demo_warning + post "toast_demo/info", to: "docs/toast_demo#info", as: :docs_toast_demo_info + post "toast_demo/with_action", to: "docs/toast_demo#with_action", as: :docs_toast_demo_with_action get "tooltip", to: "docs#tooltip", as: :docs_tooltip get "typography", to: "docs#typography", as: :docs_typography diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml index ea1c60bc4..53afa84ac 100644 --- a/gem/lib/generators/ruby_ui/dependencies.yml +++ b/gem/lib/generators/ruby_ui/dependencies.yml @@ -95,6 +95,9 @@ select: js_packages: - "@floating-ui/dom" +toast: + js_packages: [] + tooltip: js_packages: - "@floating-ui/dom" diff --git a/gem/lib/ruby_ui/toast/toast.rb b/gem/lib/ruby_ui/toast/toast.rb new file mode 100644 index 000000000..edf4f4cc7 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + module Toast + FLASH_VARIANTS = { + "notice" => :info, + "alert" => :warning, + "success" => :success, + "error" => :error, + "warning" => :warning, + "info" => :info + }.freeze + + def self.flash_variant(key) + FLASH_VARIANTS[key.to_s] || :default + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_action.rb b/gem/lib/ruby_ui/toast/toast_action.rb new file mode 100644 index 000000000..c92ec02f8 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_action.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RubyUI + class ToastAction < Base + def initialize(label:, on: nil, **attrs) + @label = label + @on = on + super(**attrs) + end + + def view_template + button(**attrs) { @label } + end + + private + + def default_attrs + data = {slot: "action"} + data[:action] = @on if @on + { + type: "button", + data: data, + class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50" + } + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_cancel.rb b/gem/lib/ruby_ui/toast/toast_cancel.rb new file mode 100644 index 000000000..88ea415d9 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_cancel.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module RubyUI + class ToastCancel < Base + def initialize(label:, **attrs) + @label = label + super(**attrs) + end + + def view_template + button(**attrs) { @label } + end + + private + + def default_attrs + { + type: "button", + data: { + slot: "cancel", + action: "click->ruby-ui--toast#dismiss" + }, + class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors" + } + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_close.rb b/gem/lib/ruby_ui/toast/toast_close.rb new file mode 100644 index 000000000..6c2151229 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_close.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module RubyUI + class ToastClose < Base + def view_template + button(**attrs) do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "14", + height: "14", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "size-3.5" + ) do |s| + s.path(d: "M18 6 6 18") + s.path(d: "m6 6 12 12") + end + span(class: "sr-only") { "Close" } + end + end + + private + + def default_attrs + { + type: "button", + aria_label: "Close toast", + data: { + slot: "close", + action: "click->ruby-ui--toast#dismiss" + }, + class: "absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring" + } + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_controller.js b/gem/lib/ruby_ui/toast/toast_controller.js new file mode 100644 index 000000000..99010a8fe --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_controller.js @@ -0,0 +1,151 @@ +import { Controller } from "@hotwired/stimulus" + +const SWIPE_THRESHOLD = 45 +const TIME_BEFORE_UNMOUNT = 200 + +// Connects to data-controller="ruby-ui--toast" +export default class extends Controller { + static values = { + duration: { type: Number, default: 4000 }, + dismissible: { type: Boolean, default: true }, + invert: { type: Boolean, default: false }, + onDismiss: String, + onAutoClose: String, + } + + connect() { + this._timer = null + this._startedAt = 0 + this._remaining = this.durationValue + this._paused = false + this._swipe = { active: false, x: 0, y: 0, startedAt: 0 } + + this._onPointerDown = this._onPointerDown.bind(this) + this._onPointerMove = this._onPointerMove.bind(this) + this._onPointerUp = this._onPointerUp.bind(this) + this._onPointerEnter = () => this._pause() + this._onPointerLeave = () => { if (!this._swipe.active) this._resume() } + this._onKeyDown = this._onKeyDown.bind(this) + this._onForceDismiss = (e) => { e.stopPropagation(); this._close() } + this._onRestart = () => this._restart() + this._onRegionPause = () => this._pause() + this._onRegionResume = () => this._resume() + + this.element.addEventListener("pointerdown", this._onPointerDown) + this.element.addEventListener("pointerenter", this._onPointerEnter) + this.element.addEventListener("pointerleave", this._onPointerLeave) + this.element.addEventListener("keydown", this._onKeyDown) + this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) + this.element.addEventListener("ruby-ui:toast:restart", this._onRestart) + document.addEventListener("ruby-ui:toast:pause", this._onRegionPause) + document.addEventListener("ruby-ui:toast:resume", this._onRegionResume) + + requestAnimationFrame(() => { + this.element.dataset.state = "open" + this._start() + }) + } + + disconnect() { + this._clearTimer() + this.element.removeEventListener("pointerdown", this._onPointerDown) + this.element.removeEventListener("pointerenter", this._onPointerEnter) + this.element.removeEventListener("pointerleave", this._onPointerLeave) + this.element.removeEventListener("keydown", this._onKeyDown) + this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) + this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart) + document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause) + document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume) + } + + dismiss(e) { + e?.preventDefault() + if (!this.dismissibleValue) return + this._close("dismiss") + } + + _close(reason) { + if (this.element.dataset.state === "closing") return + this.element.dataset.state = "closing" + this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } })) + setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT) + } + + _start() { + if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return + this._startedAt = performance.now() + this._remaining = this.durationValue + this._timer = setTimeout(() => this._close("auto"), this._remaining) + } + + _restart() { + this._clearTimer() + this._start() + } + + _pause() { + if (this._paused || !this._timer) return + this._paused = true + clearTimeout(this._timer) + this._timer = null + this._remaining -= performance.now() - this._startedAt + } + + _resume() { + if (!this._paused) return + this._paused = false + if (this._remaining <= 0) return this._close("auto") + this._startedAt = performance.now() + this._timer = setTimeout(() => this._close("auto"), this._remaining) + } + + _clearTimer() { + if (this._timer) clearTimeout(this._timer) + this._timer = null + } + + _onKeyDown(e) { + if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e) + } + + _onPointerDown(e) { + if (!this.dismissibleValue) return + if (e.target.closest("button")) return + try { this.element.setPointerCapture(e.pointerId) } catch {} + this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId } + this.element.dataset.swipe = "start" + this.element.addEventListener("pointermove", this._onPointerMove) + this.element.addEventListener("pointerup", this._onPointerUp) + this.element.addEventListener("pointercancel", this._onPointerUp) + } + + _onPointerMove(e) { + const dx = e.clientX - this._swipe.x + const dy = e.clientY - this._swipe.y + this.element.dataset.swipe = "move" + this.element.style.transform = `translate(${dx}px, ${dy}px)` + } + + _onPointerUp(e) { + const dx = e.clientX - this._swipe.x + const dy = e.clientY - this._swipe.y + const dist = Math.hypot(dx, dy) + const dt = performance.now() - this._swipe.startedAt + const velocity = dist / Math.max(dt, 1) + this.element.removeEventListener("pointermove", this._onPointerMove) + this.element.removeEventListener("pointerup", this._onPointerUp) + this.element.removeEventListener("pointercancel", this._onPointerUp) + this._swipe.active = false + if (dist > SWIPE_THRESHOLD || velocity > 0.5) { + this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`) + this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`) + this.element.dataset.swipe = "end" + this.element.style.transform = "" + this._close("dismiss") + } else { + this.element.dataset.swipe = "cancel" + this.element.style.transform = "" + this._resume() + } + } +} diff --git a/gem/lib/ruby_ui/toast/toast_description.rb b/gem/lib/ruby_ui/toast/toast_description.rb new file mode 100644 index 000000000..955b5d182 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_description.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class ToastDescription < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "description"}, + class: "font-normal leading-[1.4] text-muted-foreground" + } + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_docs.rb b/gem/lib/ruby_ui/toast/toast_docs.rb new file mode 100644 index 000000000..3cdbd2927 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_docs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module RubyUI + class ToastDocs < Phlex::HTML + def view_template + div(class: "space-y-4") do + h2 { "Toast" } + p { "Hotwire-native sonner port. Mount once; trigger via Turbo Stream or window.RubyUI.toast." } + end + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_icon.rb b/gem/lib/ruby_ui/toast/toast_icon.rb new file mode 100644 index 000000000..9d0ee9a4a --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_icon.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module RubyUI + class ToastIcon < Base + def initialize(variant: nil, **attrs) + @variant = variant&.to_sym + super(**attrs) + end + + def view_template + return unless renderable? + span(**attrs) do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "16", + height: "16", + viewbox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: "#{svg_classes} -ml-px" + ) { |s| paths(s) } + end + end + + private + + def renderable? + %i[success error warning info loading].include?(@variant) + end + + def svg_classes + base = "size-4" + (@variant == :loading) ? "#{base} animate-spin" : base + end + + def paths(s) + case @variant + when :success + s.circle(cx: "12", cy: "12", r: "10") + s.path(d: "m9 12 2 2 4-4") + when :error + s.path(d: "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z") + s.path(d: "m15 9-6 6") + s.path(d: "m9 9 6 6") + when :warning + s.path(d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3") + s.path(d: "M12 9v4") + s.path(d: "M12 17h.01") + when :info + s.circle(cx: "12", cy: "12", r: "10") + s.path(d: "M12 16v-4") + s.path(d: "M12 8h.01") + when :loading + s.path(d: "M21 12a9 9 0 1 1-6.219-8.56") + end + end + + def default_attrs + {data: {slot: "icon"}, class: "shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground"} + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_item.rb b/gem/lib/ruby_ui/toast/toast_item.rb new file mode 100644 index 000000000..e2052d42c --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_item.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module RubyUI + class ToastItem < Base + ALERT_VARIANTS = %i[error].freeze + + def initialize( + variant: :default, + id: nil, + duration: nil, + dismissible: true, + invert: false, + on_dismiss: nil, + on_auto_close: nil, + **attrs + ) + @variant = variant.to_sym + @id = id + @duration = duration + @dismissible = dismissible + @invert = invert + @on_dismiss = on_dismiss + @on_auto_close = on_auto_close + super(**attrs) + end + + def view_template(&) + li(**attrs, &) + end + + private + + def default_attrs + a = { + role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status", + aria_atomic: "true", + tabindex: "0", + data: { + variant: @variant.to_s, + state: "pending", + swipe: "none", + controller: "ruby-ui--toast", + ruby_ui__toaster_target: "toast", + ruby_ui__toast_dismissible_value: @dismissible.to_s, + ruby_ui__toast_invert_value: @invert.to_s + }, + class: item_classes + } + a[:id] = @id if @id + a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration + a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss + a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close + a + end + + def item_classes + <<~CLASSES.tr("\n", " ").squeeze(" ").strip + group/toast pointer-events-auto absolute left-0 right-0 + flex w-[356px] max-w-full items-center gap-1.5 + rounded-lg border bg-popover text-popover-foreground + border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)] + group-data-[close-button=true]/toaster:pr-10 + transition-[transform,opacity] duration-300 ease-out + will-change-transform + opacity-[var(--opacity,1)] + data-[state=pending]:opacity-0 + data-[state=closing]:opacity-0 + data-[swipe=move]:transition-none + CLASSES + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_region.rb b/gem/lib/ruby_ui/toast/toast_region.rb new file mode 100644 index 000000000..c8cc6e901 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_region.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module RubyUI + class ToastRegion < Base + SKELETON_VARIANTS = %i[default success error warning info loading].freeze + + def initialize( + position: :bottom_right, + expand: false, + max: 3, + duration: 4000, + gap: 14, + offset: 24, + theme: :system, + rich_colors: false, + close_button: false, + hotkey: %w[alt t], + dir: :ltr, + flash: nil, + **attrs + ) + @position = position.to_sym + @expand = expand + @max = max + @duration = duration + @gap = gap + @offset = offset + @theme = theme.to_sym + @rich_colors = rich_colors + @close_button = close_button + @hotkey = hotkey + @dir = dir + @flash = flash + super(**attrs) + end + + def view_template(&block) + div(**attrs) do + ol(id: "ruby-ui-toaster", class: "pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full") do + render_flash if @flash + yield(self) if block + end + SKELETON_VARIANTS.each { |v| skeleton(v) } + slot_template("actionTpl") { render RubyUI::ToastAction.new(label: "") } + slot_template("cancelTpl") { render RubyUI::ToastCancel.new(label: "") } + slot_template("closeTpl") { render RubyUI::ToastClose.new } + end + end + + private + + def render_flash + @flash.each do |key, message| + next if message.nil? || message.to_s.empty? + variant = RubyUI::Toast.flash_variant(key) + render RubyUI::ToastItem.new(variant: variant, id: "flash-#{key}") do + render RubyUI::ToastIcon.new(variant: variant) + render RubyUI::ToastTitle.new { message.to_s } + end + end + end + + def skeleton(variant) + template( + data: { + ruby_ui__toaster_target: "skeleton", + variant: variant.to_s + } + ) do + render RubyUI::ToastItem.new(variant: variant) do + render RubyUI::ToastIcon.new(variant: variant) + div(class: "flex flex-col gap-0.5 flex-1 min-w-0") do + render RubyUI::ToastTitle.new + render RubyUI::ToastDescription.new + end + render RubyUI::ToastClose.new if @close_button + end + end + end + + def slot_template(target_name, &) + template(data: {ruby_ui__toaster_target: target_name}, &) + end + + def default_attrs + { + id: "ruby-ui-toaster-region", + role: "region", + aria_label: "Notifications", + aria_live: "polite", + data: { + controller: "ruby-ui--toaster", + turbo_permanent: "", + close_button: @close_button.to_s, + position: @position.to_s.tr("_", "-"), + ruby_ui__toaster_position_value: @position.to_s.tr("_", "-"), + ruby_ui__toaster_expand_value: @expand.to_s, + ruby_ui__toaster_max_value: @max.to_s, + ruby_ui__toaster_duration_value: @duration.to_s, + ruby_ui__toaster_gap_value: @gap.to_s, + ruby_ui__toaster_offset_value: @offset.to_s, + ruby_ui__toaster_theme_value: @theme.to_s, + ruby_ui__toaster_rich_colors_value: @rich_colors.to_s, + ruby_ui__toaster_close_button_value: @close_button.to_s, + ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+"), + ruby_ui__toaster_dir_value: @dir.to_s + }, + class: region_classes + } + end + + def region_classes + <<~CLASSES.tr("\n", " ").squeeze(" ").strip + group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6 + data-[position=top-left]:top-0 data-[position=top-left]:left-0 + data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2 + data-[position=top-right]:top-0 data-[position=top-right]:right-0 + data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0 + data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2 + data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0 + CLASSES + end + end +end diff --git a/gem/lib/ruby_ui/toast/toast_title.rb b/gem/lib/ruby_ui/toast/toast_title.rb new file mode 100644 index 000000000..b48dbfbe1 --- /dev/null +++ b/gem/lib/ruby_ui/toast/toast_title.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class ToastTitle < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {slot: "title"}, + class: "font-medium leading-normal" + } + end + end +end diff --git a/gem/lib/ruby_ui/toast/toaster_controller.js b/gem/lib/ruby_ui/toast/toaster_controller.js new file mode 100644 index 000000000..b3976c5bd --- /dev/null +++ b/gem/lib/ruby_ui/toast/toaster_controller.js @@ -0,0 +1,306 @@ +import { Controller } from "@hotwired/stimulus" + +const VARIANTS = ["default", "success", "error", "warning", "info", "loading"] + +let streamActionRegistered = false + +function registerStreamAction() { + if (streamActionRegistered) return + if (typeof window === "undefined") return + const Turbo = window.Turbo + if (!Turbo?.StreamActions) return + Turbo.StreamActions.toast = function () { + const detail = {} + for (const attr of this.attributes) { + if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue + detail[attr.name] = attr.value + } + if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration) + if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false" + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail })) + } + streamActionRegistered = true +} + +// Connects to data-controller="ruby-ui--toaster" +export default class extends Controller { + static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"] + static values = { + position: { type: String, default: "bottom-right" }, + expand: { type: Boolean, default: false }, + max: { type: Number, default: 3 }, + duration: { type: Number, default: 4000 }, + gap: { type: Number, default: 14 }, + offset: { type: Number, default: 24 }, + theme: { type: String, default: "system" }, + richColors: { type: Boolean, default: false }, + closeButton: { type: Boolean, default: false }, + hotkey: { type: String, default: "alt+t" }, + dir: { type: String, default: "ltr" }, + } + + connect() { + this._heights = new Map() + this._resizeObservers = new WeakMap() + this._expanded = this.expandValue + this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) + this._registerGlobalApi() + registerStreamAction() + if (!this._listEl) return + + this._onPointerEnter = () => this._setExpanded(true) + this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) } + this._onWindowToast = (e) => this._spawn(e.detail || {}) + this._onWindowDismissAll = () => this._dismissById(null) + this._onKey = this._onKey.bind(this) + + window.addEventListener("ruby-ui:toast", this._onWindowToast) + window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) + this._listEl.addEventListener("pointerenter", this._onPointerEnter) + this._listEl.addEventListener("pointerleave", this._onPointerLeave) + document.addEventListener("keydown", this._onKey) + } + + disconnect() { + window.removeEventListener("ruby-ui:toast", this._onWindowToast) + window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) + this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) + this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) + document.removeEventListener("keydown", this._onKey) + } + + toastTargetConnected(el) { + if (typeof ResizeObserver !== "undefined") { + const ro = new ResizeObserver(() => { + this._heights.set(el, el.offsetHeight) + this._reflow() + }) + ro.observe(el) + this._resizeObservers.set(el, ro) + } + this._heights.set(el, el.offsetHeight || 64) + this._reflow() + } + + toastTargetDisconnected(el) { + this._resizeObservers.get(el)?.disconnect() + this._resizeObservers.delete(el) + this._heights.delete(el) + this._reflow() + } + + _spawn(detail) { + 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 + } + const node = tpl.content.firstElementChild.cloneNode(true) + + node.id = detail.id || `toast-${this._uuid()}` + if (detail.duration != null) { + const dur = detail.duration === Infinity ? 0 : detail.duration + node.setAttribute("data-ruby-ui--toast-duration-value", String(dur)) + } + if (detail.dismissible === false) { + node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") + } + if (detail.className) node.className += ` ${detail.className}` + + const titleEl = node.querySelector('[data-slot="title"]') + if (titleEl) titleEl.textContent = detail.title || detail.message || "" + const descEl = node.querySelector('[data-slot="description"]') + if (descEl) { + if (detail.description) descEl.textContent = detail.description + else descEl.remove() + } + + if (detail.action && detail.action.label && this.hasActionTplTarget) { + const btn = this._cloneSlot(this.actionTplTarget) + btn.textContent = detail.action.label + btn.addEventListener("click", (ev) => { + try { detail.action.onClick?.(ev) } finally { + node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + }) + node.appendChild(btn) + } + + if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) { + const btn = this._cloneSlot(this.cancelTplTarget) + btn.textContent = detail.cancel.label + node.appendChild(btn) + } + + if (detail.closeButton && this.hasCloseTplTarget) { + const x = this._cloneSlot(this.closeTplTarget) + node.classList.add("pr-10") + node.appendChild(x) + } + + this._listEl.appendChild(node) + return node.id + } + + _dismissById(id) { + if (!id) { + this.toastTargets.forEach((el) => + el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + ) + return + } + const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + + _skeletonFor(variant) { + return this.skeletonTargets.find((t) => t.dataset.variant === variant) + } + + _cloneSlot(tpl) { + return tpl.content.firstElementChild.cloneNode(true) + } + + _setExpanded(value) { + if (this._expanded === value) return + this._expanded = value + document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume")) + this._reflow() + } + + _reflow() { + if (!this._listEl) return + const isBottom = this.positionValue.startsWith("bottom") + const items = this.toastTargets + const order = isBottom ? items.slice().reverse() : items.slice() + const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64) + const gap = this.gapValue + const peekOffset = 16 + const peekScaleStep = 0.05 + const peekOpacityStep = 0.2 + + const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1) + const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset + this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px` + + let acc = 0 + order.forEach((el, i) => { + const visible = i < this.maxValue + let yOffset, scale, opacity + + if (this._expanded) { + yOffset = acc + i * gap + scale = 1 + opacity = visible ? 1 : 0 + } else { + yOffset = i * peekOffset + scale = Math.max(0.85, 1 - i * peekScaleStep) + opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0 + } + + const sign = isBottom ? -1 : 1 + const ty = sign * yOffset + + el.style.setProperty("--opacity", String(opacity)) + el.style.setProperty("--scale", String(scale)) + el.style.setProperty("--y-offset", `${ty}px`) + el.style.transformOrigin = isBottom ? "center bottom" : "center top" + el.style.top = isBottom ? "auto" : "0" + el.style.bottom = isBottom ? "0" : "auto" + el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})` + el.style.zIndex = String(1000 - i) + el.style.pointerEvents = visible ? "auto" : "none" + el.tabIndex = visible ? 0 : -1 + + acc += heights[i] || 0 + }) + + this._enforceMax(items) + } + + _enforceMax(items) { + if (items.length <= this.maxValue) return + const isBottom = this.positionValue.startsWith("bottom") + const dropping = items.length - this.maxValue + const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping) + candidates.forEach(el => { + if (el.dataset.state !== "closing") { + el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) + } + }) + } + + _onKey(e) { + const parts = (this.hotkeyValue || "alt+t").split("+") + const key = parts.pop() + const wantAlt = parts.includes("alt") + const wantCtrl = parts.includes("ctrl") + const wantMeta = parts.includes("meta") + if (e.key.toLowerCase() !== key.toLowerCase()) return + if (wantAlt !== e.altKey) return + if (wantCtrl !== e.ctrlKey) return + if (wantMeta !== e.metaKey) return + e.preventDefault() + const first = this._listEl.firstElementChild + first?.focus() + } + + _registerGlobalApi() { + const fire = (variant, message, opts = {}) => + window.dispatchEvent(new CustomEvent("ruby-ui:toast", { + detail: { ...opts, variant, message: opts.title || message } + })) + + const api = (message, opts) => fire("default", message, opts) + api.success = (m, o) => fire("success", m, o) + api.error = (m, o) => fire("error", m, o) + api.warning = (m, o) => fire("warning", m, o) + api.info = (m, o) => fire("info", m, o) + api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 }) + api.dismiss = (id) => { + if (id) this._dismissById(id) + else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all")) + } + api.promise = (p, msgs = {}) => { + const id = `toast-${this._uuid()}` + fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 }) + Promise.resolve(p).then( + (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success), + (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error) + ) + return id + } + + window.RubyUI = window.RubyUI || {} + window.RubyUI.toast = api + } + + _mutate(id, variant, text) { + const el = this._listEl.querySelector(`#${CSS.escape(id)}`) + if (!el) return + el.dataset.variant = variant + el.setAttribute("role", variant === "error" ? "alert" : "status") + this._swapIcon(el, variant) + const t = el.querySelector('[data-slot="title"]') + if (t && text) t.textContent = text + const dur = String(this.durationValue) + el.setAttribute("data-ruby-ui--toast-duration-value", dur) + el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true })) + } + + _swapIcon(el, variant) { + const iconHost = el.querySelector('[data-slot="icon"]') + if (!iconHost) return + const tpl = this._skeletonFor(variant) + if (!tpl) return + const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]') + iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : "" + } + + _uuid() { + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID() + return Math.random().toString(36).slice(2) + Date.now().toString(36) + } +} diff --git a/gem/test/ruby_ui/toast_item_test.rb b/gem/test/ruby_ui/toast_item_test.rb new file mode 100644 index 000000000..c44ffc67b --- /dev/null +++ b/gem/test/ruby_ui/toast_item_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::ToastItemTest < ComponentTest + def test_renders_default_variant_with_status_role + out = phlex { RubyUI.ToastItem { "hi" } } + assert_match(/data-variant="default"/, out) + assert_match(/role="status"/, out) + assert_match(/data-controller="ruby-ui--toast"/, out) + assert_match(/data-ruby-ui--toaster-target="toast"/, out) + assert_match(/data-state="pending"/, out) + end + + def test_error_variant_uses_alert_role + out = phlex { RubyUI.ToastItem(variant: :error) { "x" } } + assert_match(/role="alert"/, out) + assert_match(/data-variant="error"/, out) + end + + def test_duration_propagated_as_data_value + out = phlex { RubyUI.ToastItem(duration: 7000) { "x" } } + assert_match(/data-ruby-ui--toast-duration-value="7000"/, out) + end + + def test_dismissible_default_true + out = phlex { RubyUI.ToastItem { "x" } } + assert_match(/data-ruby-ui--toast-dismissible-value="true"/, out) + end + + def test_id_attribute + out = phlex { RubyUI.ToastItem(id: "abc") { "x" } } + assert_match(/id="abc"/, out) + end +end diff --git a/gem/test/ruby_ui/toast_region_test.rb b/gem/test/ruby_ui/toast_region_test.rb new file mode 100644 index 000000000..9b0376a3a --- /dev/null +++ b/gem/test/ruby_ui/toast_region_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::ToastRegionTest < ComponentTest + def test_renders_ol_with_target_id + out = phlex { RubyUI.ToastRegion() } + assert_match(/]*id="ruby-ui-toaster"/, out) + assert_match(/data-controller="ruby-ui--toaster"/, out) + end + + def test_outer_wrapper_has_id_and_turbo_permanent + out = phlex { RubyUI.ToastRegion() } + assert_match(/]*id="ruby-ui-toaster-region"/, out) + assert_match(/data-turbo-permanent/, out) + end + + def test_position_attr + out = phlex { RubyUI.ToastRegion(position: :top_left) } + assert_match(/data-position="top-left"/, out) + end + + def test_default_value_props + out = phlex { RubyUI.ToastRegion(max: 5, duration: 8000) } + assert_match(/data-ruby-ui--toaster-max-value="5"/, out) + assert_match(/data-ruby-ui--toaster-duration-value="8000"/, out) + end + + def test_renders_skeleton_per_variant + out = phlex { RubyUI.ToastRegion() } + %w[default success error warning info loading].each do |v| + assert_match(/data-variant="#{v}"/, out) + end + end + + def test_role_region_aria_live + out = phlex { RubyUI.ToastRegion() } + assert_match(/role="region"/, out) + assert_match(/aria-live="polite"/, out) + end + + def test_renders_flash_messages + out = phlex { RubyUI.ToastRegion(flash: {"notice" => "Saved", "alert" => "Oops"}) } + assert_match(/Saved/, out) + assert_match(/Oops/, out) + assert_match(/id="flash-notice"/, out) + end +end diff --git a/gem/test/ruby_ui/toast_test.rb b/gem/test/ruby_ui/toast_test.rb new file mode 100644 index 000000000..71eb93f30 --- /dev/null +++ b/gem/test/ruby_ui/toast_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::ToastTest < ComponentTest + def test_compose_region_with_item_and_slots + out = phlex { + RubyUI.ToastRegion { + RubyUI.ToastItem(variant: :success) { + RubyUI.ToastIcon(variant: :success) + RubyUI.ToastTitle { "Saved" } + RubyUI.ToastDescription { "All good" } + RubyUI.ToastAction(label: "Undo", on: "click->thing#undo") + RubyUI.ToastClose() + } + } + } + assert_match(/Saved/, out) + assert_match(/All good/, out) + assert_match(/Undo/, out) + assert_match(/data-slot="close"/, out) + assert_match(/data-slot="icon"/, out) + end + + def test_flash_variant_helper + assert_equal :info, RubyUI::Toast.flash_variant(:notice) + assert_equal :warning, RubyUI::Toast.flash_variant("alert") + assert_equal :default, RubyUI::Toast.flash_variant(:unknown) + end + + def test_action_renders_label_and_action_attr + out = phlex { RubyUI.ToastAction(label: "Undo", on: "click->thing#undo") } + assert_match(/Undo/, out) + assert_match(/data-slot="action"/, out) + assert_match(/data-action="click->thing#undo"/, out) + end + + def test_cancel_renders_label_and_dismiss_action + out = phlex { RubyUI.ToastCancel(label: "Dismiss") } + assert_match(/Dismiss/, out) + assert_match(/data-slot="cancel"/, out) + assert_match(/click->ruby-ui--toast#dismiss|click->ruby-ui--toast#dismiss/, out) + end + + def test_icon_renders_per_variant + { + success: /circle[^>]*r="10"/, + info: /M12 16v-4/, + warning: /m21\.73 18/, + error: /6\.624a2 2 0 0 1 1\.414/, + loading: /animate-spin/ + }.each do |variant, pattern| + out = phlex { RubyUI.ToastIcon(variant: variant) } + assert_match(pattern, out, "expected #{variant} icon to match #{pattern.inspect}") + end + end + + def test_icon_default_variant_renders_nothing + out = phlex { RubyUI.ToastIcon(variant: :default) } + refute_match(/ruby-ui--toast#dismiss/, out) + assert_match(/sr-only/, out) + end + + def test_title_and_description_slot_attrs + title_out = phlex { RubyUI.ToastTitle { "Saved" } } + desc_out = phlex { RubyUI.ToastDescription { "details" } } + assert_match(/data-slot="title"/, title_out) + assert_match(/Saved/, title_out) + assert_match(/data-slot="description"/, desc_out) + assert_match(/details/, desc_out) + end +end