ReviewOS

stacks/stx

Built-in Toast component (<StxToast />) and toast() composable

#1671
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

Add a built-in toast notification system to stx with two parts:

  1. <StxToast /> — a builtin component (like <Icon />, <StxLink />) that renders the toast container
  2. toast() — a runtime global function (like state(), onMount()) for triggering toasts from any <script client> block

Usage

Layout (place once)

<!-- resources/layouts/app.stx -->
@include('styles')
@include('theme')
@include('nav')

<main>
  @slot('content')
</main>

<StxToast />

<StxToast /> renders an empty container (top-right fixed position) that toasts get appended to. It should be placed once in a layout — not per-page.

Triggering toasts

<script client>
  // Available as a runtime global (no import needed in <script client>)
  toast.success('Settings saved!')
  toast.error('Failed to save')
  toast.info('Syncing your data...')
  toast.warning('You are offline')

  // With options
  toast.success('Saved!', { duration: 5000 })
  toast.error('Network error', { duration: 0 }) // persistent until dismissed
</script>

Importable composable (for store files)

// resources/stores/auth.ts
import { toast } from '@stacksjs/stx/composables'

// or if auto-imported in <script client>, just use toast directly

API

toast global

interface ToastOptions {
  /** Duration in ms before auto-dismiss. Default: 3000. Set 0 for persistent. */
  duration?: number
  /** Position. Default: 'top-right' */
  position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'
}

toast.success(message: string, options?: ToastOptions): void
toast.error(message: string, options?: ToastOptions): void
toast.info(message: string, options?: ToastOptions): void
toast.warning(message: string, options?: ToastOptions): void
toast.dismiss(id?: string): void  // dismiss specific or all

<StxToast /> component

Props (optional):

  • position — default position for all toasts (default: 'top-right')
  • max — max visible toasts (default: 5, older ones get dismissed)
<StxToast position="top-right" max="5" />

Rendered HTML (injected at build time):

<div id="stx-toast-container" class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 pointer-events-none" style="max-width: 400px;">
  <!-- toasts get appended here dynamically -->
</div>

Each toast element:

<div class="stx-toast stx-toast-success pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 animate-slide-in" role="alert">
  <span class="stx-toast-icon shrink-0"><!-- success/error/info/warning icon --></span>
  <p class="stx-toast-message flex-1 text-sm text-gray-900 dark:text-white">Settings saved!</p>
  <button class="stx-toast-close shrink-0 text-gray-400 hover:text-gray-600" aria-label="Close">&times;</button>
</div>

Implementation notes

Runtime (signals.ts)

Add toast to the global scope alongside state, onMount, etc:

// In the IIFE that sets up globals
const toastQueue = []
let toastContainer = null

window.toast = {
  success(msg, opts) { addToast('success', msg, opts) },
  error(msg, opts)   { addToast('error', msg, opts) },
  info(msg, opts)    { addToast('info', msg, opts) },
  warning(msg, opts) { addToast('warning', msg, opts) },
  dismiss(id)        { removeToast(id) },
}

function addToast(type, message, options = {}) {
  const id = 'toast-' + Date.now() + '-' + Math.random().toString(36).slice(2)
  const duration = options.duration ?? 3000

  // Find container (created by <StxToast /> builtin)
  if (!toastContainer) toastContainer = document.getElementById('stx-toast-container')
  if (!toastContainer) return // no <StxToast /> in layout

  // Create toast element
  const el = document.createElement('div')
  el.id = id
  el.className = 'stx-toast stx-toast-' + type + ' ...'
  el.innerHTML = '...'
  toastContainer.appendChild(el)

  // Auto-dismiss
  if (duration > 0) setTimeout(() => removeToast(id), duration)

  // Dismiss on click
  el.querySelector('.stx-toast-close')?.addEventListener('click', () => removeToast(id))
}

function removeToast(id) {
  const el = document.getElementById(id)
  if (el) {
    el.classList.add('animate-slide-out')
    setTimeout(() => el.remove(), 300)
  }
}

Builtin component (builtins/toast.ts)

Like builtins/icon.ts, register a StxToast builtin that renders the container div at template processing time:

export const toastBuiltin: BuiltinComponentDef = {
  name: 'StxToast',
  selfClosing: true,
  render(props) {
    const position = props.position || 'top-right'
    const positionClasses = {
      'top-right': 'top-4 right-4',
      'top-left': 'top-4 left-4',
      'bottom-right': 'bottom-4 right-4',
      'bottom-left': 'bottom-4 left-4',
      'top-center': 'top-4 left-1/2 -translate-x-1/2',
      'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
    }
    return `<div id="stx-toast-container" class="fixed ${positionClasses[position]} z-[9999] flex flex-col gap-2 pointer-events-none" style="max-width:400px"></div>`
  },
}

Composable (composables/use-toast.ts)

For store files that can't access runtime globals:

export function useToast() {
  return {
    success: (msg: string, opts?: ToastOptions) => (window as any).toast?.success(msg, opts),
    error: (msg: string, opts?: ToastOptions) => (window as any).toast?.error(msg, opts),
    info: (msg: string, opts?: ToastOptions) => (window as any).toast?.info(msg, opts),
    warning: (msg: string, opts?: ToastOptions) => (window as any).toast?.warning(msg, opts),
    dismiss: (id?: string) => (window as any).toast?.dismiss(id),
  }
}

CSS animations

Include these in the signals runtime CSS injection (or Crosswind defaults):

@keyframes stx-slide-in {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}
@keyframes stx-slide-out {
  from { transform: translateX(0); opacity: 1; }
  to { transform: translateX(100%); opacity: 0; }
}
.stx-toast { animation: stx-slide-in 0.3s ease-out; }
.stx-toast-dismiss { animation: stx-slide-out 0.3s ease-in forwards; }

Toast type styling

Each type gets an accent color on the left border or icon:

  • success — green left border, checkmark icon
  • error — red left border, x-circle icon
  • info — blue left border, info icon
  • warning — yellow left border, alert-triangle icon

These should use Crosswind utility classes and work with dark mode.

Files to create/modify

  • packages/stx/src/builtins/toast.ts — builtin component registration
  • packages/stx/src/builtins/index.ts — register the toast builtin
  • packages/stx/src/signals.ts — add toast global + addToast/removeToast functions
  • packages/stx/src/composables/use-toast.ts — composable wrapper
  • packages/stx/src/composables/index.ts — export useToast

Why built-in vs userland

Toast is one of those primitives every app needs. Making it a builtin means:

  • Zero dependencies (no external toast library)
  • Works with stx's signals reactivity out of the box
  • Consistent dark mode support via Crosswind
  • Available as a runtime global — no import ceremony in <script client> blocks
  • Tiny footprint — just DOM manipulation, no virtual DOM

Sign in to comment on this issue.