{"name":"toast","type":"registry:component","title":"Toast","description":"An opinionated toast notification component for displaying messages. Built with Lit and styled using Tailwind CSS. Supports success, error, info, warning, and loading states with icons. Features promise support, action buttons, and multiple visual variants.","categories":["ui","toast","notification","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","registryDependencies":["@lit/button"],"dependencies":["lucide-static"],"files":[{"path":"registry/ui/toast/toast.ts","type":"registry:ui","content":"import { cva, type VariantProps } from \"class-variance-authority\";\nimport { html, LitElement, nothing } from \"lit\";\nimport { customElement, state } from \"lit/decorators.js\";\nimport { repeat } from \"lit/directives/repeat.js\";\nimport { unsafeHTML } from \"lit/directives/unsafe-html.js\";\nimport {\n  CircleCheck,\n  Info,\n  LoaderCircle,\n  OctagonX,\n  TriangleAlert,\n  X,\n} from \"lucide-static\";\nimport { TW } from \"@/lib/tailwindMixin\";\nimport \"../button/button\";\n\nexport const toastVariants = cva(\n  \"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md p-4 pr-6 shadow-lg transition-all\",\n  {\n    variants: {\n      variant: {\n        default: \"bg-popover text-popover-foreground border\",\n        outline: \"bg-background text-foreground border\",\n        destructive:\n          \"bg-destructive text-destructive-foreground border-destructive\",\n      },\n      state: {\n        open: \"animate-in fade-in-0 slide-in-from-top-full duration-300 sm:slide-in-from-bottom-full\",\n        closed: \"animate-out fade-out-80 slide-out-to-right-full duration-300\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n    },\n  },\n);\n\ntype ToastVariants = VariantProps<typeof toastVariants>;\n\nexport type ToastType = \"success\" | \"error\" | \"info\" | \"warning\" | \"loading\";\n\nexport interface ToastOptions {\n  description?: string;\n  variant?: ToastVariants[\"variant\"];\n  type?: ToastType;\n  duration?: number;\n  action?: {\n    label: string;\n    onClick: () => void;\n  };\n  onDismiss?: () => void;\n}\n\ninterface ToastData extends ToastOptions {\n  id: string;\n  message: string;\n  state: \"open\" | \"closed\";\n}\n\nlet toasterInstance: Toaster | null = null;\nlet toastIdCounter = 0;\n\nfunction ensureToaster(): Toaster {\n  if (!toasterInstance) {\n    toasterInstance = document.createElement(\"ui-toaster\") as Toaster;\n    document.body.appendChild(toasterInstance);\n  }\n  return toasterInstance;\n}\n\nexport function toast(message: string, options: ToastOptions = {}) {\n  const toaster = ensureToaster();\n  return toaster.addToast(message, options);\n}\n\ntoast.success = (message: string, options: ToastOptions = {}) =>\n  toast(message, { ...options, type: \"success\" });\n\ntoast.error = (message: string, options: ToastOptions = {}) =>\n  toast(message, { ...options, type: \"error\" });\n\ntoast.warning = (message: string, options: ToastOptions = {}) =>\n  toast(message, { ...options, type: \"warning\" });\n\ntoast.info = (message: string, options: ToastOptions = {}) =>\n  toast(message, { ...options, type: \"info\" });\n\ntoast.promise = async <T>(\n  promise: Promise<T> | (() => Promise<T>),\n  messages: {\n    loading: string;\n    success: string | ((data: T) => string);\n    error: string | ((error: Error) => string);\n  },\n  options: ToastOptions = {},\n): Promise<T> => {\n  const toaster = ensureToaster();\n  const loadingToastId = toaster.addToast(messages.loading, {\n    ...options,\n    type: \"loading\",\n    duration: Number.POSITIVE_INFINITY,\n  });\n\n  try {\n    const data = await (typeof promise === \"function\" ? promise() : promise);\n    toaster.dismissToast(loadingToastId);\n    const successMessage =\n      typeof messages.success === \"function\"\n        ? messages.success(data)\n        : messages.success;\n    toast.success(successMessage, options);\n    return data;\n  } catch (error) {\n    toaster.dismissToast(loadingToastId);\n    const errorMessage =\n      typeof messages.error === \"function\"\n        ? messages.error(error as Error)\n        : messages.error;\n    toast.error(errorMessage, options);\n    throw error;\n  }\n};\n\n@customElement(\"ui-toaster\")\nexport class Toaster extends TW(LitElement) {\n  @state() private toasts: ToastData[] = [];\n  private maxToasts = 3;\n  private timers = new Map<string, number>();\n  private animationDuration = 300;\n  private prefersReducedMotion = window.matchMedia(\n    \"(prefers-reduced-motion: reduce)\",\n  ).matches;\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    for (const timerId of this.timers.values()) {\n      clearTimeout(timerId);\n    }\n    this.timers.clear();\n  }\n\n  addToast(message: string, options: ToastOptions = {}): string {\n    const id = `toast-${++toastIdCounter}`;\n    const duration = options.duration ?? 5000;\n\n    const toast: ToastData = {\n      id,\n      message,\n      state: \"open\",\n      ...options,\n    };\n\n    this.toasts.push(toast);\n    this.requestUpdate();\n\n    if (this.toasts.length > this.maxToasts) {\n      const oldestToast = this.toasts[0];\n      this.dismissToast(oldestToast.id);\n    }\n\n    if (duration !== Number.POSITIVE_INFINITY) {\n      const timerId = window.setTimeout(() => {\n        this.dismissToast(id);\n      }, duration);\n      this.timers.set(id, timerId);\n    }\n\n    return id;\n  }\n\n  dismissToast(id: string) {\n    const index = this.toasts.findIndex((t) => t.id === id);\n    if (index === -1) return;\n\n    const timerId = this.timers.get(id);\n    if (timerId !== undefined) {\n      clearTimeout(timerId);\n      this.timers.delete(id);\n    }\n\n    this.toasts[index].state = \"closed\";\n    this.requestUpdate();\n\n    const cleanupDelay = this.prefersReducedMotion ? 0 : this.animationDuration;\n    setTimeout(() => {\n      const toastIndex = this.toasts.findIndex((t) => t.id === id);\n      if (toastIndex !== -1) {\n        const toast = this.toasts[toastIndex];\n        this.toasts.splice(toastIndex, 1);\n        this.requestUpdate();\n        toast?.onDismiss?.();\n      }\n    }, cleanupDelay);\n  }\n\n  private getIcon(type?: ToastType) {\n    switch (type) {\n      case \"success\":\n        return CircleCheck;\n      case \"info\":\n        return Info;\n      case \"warning\":\n        return TriangleAlert;\n      case \"error\":\n        return OctagonX;\n      case \"loading\":\n        return LoaderCircle;\n      default:\n        return null;\n    }\n  }\n\n  private renderToast(toast: ToastData) {\n    const icon = this.getIcon(toast.type);\n\n    return html`\n      <div\n        class=${toastVariants({ variant: toast.variant, state: toast.state })}\n        role=${toast.type === \"error\" ? \"alert\" : \"status\"}\n        aria-live=${toast.type === \"error\" ? \"assertive\" : \"polite\"}\n        aria-atomic=\"true\"\n      >\n        ${\n          icon\n            ? html`<div\n              class=\"[&>svg]:size-4 shrink-0 ${\n                toast.type === \"loading\" ? \"animate-spin\" : \"\"\n              }\"\n            >\n              ${unsafeHTML(icon)}\n            </div>`\n            : nothing\n        }\n        <div class=\"grid gap-1 flex-1\">\n          <div class=\"text-sm font-semibold [&+div]:text-xs\">\n            ${toast.message}\n          </div>\n          ${\n            toast.description\n              ? html`<div class=\"text-sm opacity-90\">${toast.description}</div>`\n              : nothing\n          }\n        </div>\n        ${\n          toast.action\n            ? html`\n              <ui-button\n                variant=\"outline\"\n                size=\"sm\"\n                @click=${() => {\n                  toast.action?.onClick();\n                  this.dismissToast(toast.id);\n                }}\n              >\n                ${toast.action.label}\n              </ui-button>\n            `\n            : nothing\n        }\n        <ui-button\n          class=\"absolute right-1 top-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100\"\n          variant=\"ghost\"\n          size=\"icon-sm\"\n          @click=${() => this.dismissToast(toast.id)}\n          aria-label=\"Close\"\n        >\n          ${unsafeHTML(X)}\n        </ui-button>\n      </div>\n    `;\n  }\n\n  override render() {\n    return html`\n      <div\n        class=\"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]\"\n        aria-live=\"polite\"\n        aria-label=\"Notifications\"\n      >\n        ${repeat(\n          this.toasts,\n          (toast) => toast.id,\n          (toast) => this.renderToast(toast),\n        )}\n      </div>\n    `;\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-toaster\": Toaster;\n  }\n}\n"},{"path":"registry/ui/toast/toast.stories.ts","type":"registry:ui","content":"import type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport \"../button/button\";\nimport \"./toast\";\nimport { type ToastOptions, type ToastType, toast } from \"./toast\";\n\ntype ToastArgs = {\n  message: string;\n  description?: string;\n  type?: ToastType;\n  variant?: ToastOptions[\"variant\"];\n  showAction?: boolean;\n  actionLabel?: string;\n  duration?: number;\n};\n\n/**\n * An opinionated toast component for displaying notifications.\n */\nconst meta: Meta<ToastArgs> = {\n  title: \"ui/Toast\",\n  component: \"ui-toaster\",\n  tags: [\"autodocs\"],\n  parameters: {\n    layout: \"fullscreen\",\n  },\n  argTypes: {\n    message: {\n      control: \"text\",\n      description: \"The main message of the toast\",\n    },\n    description: {\n      control: \"text\",\n      description: \"Optional description text\",\n    },\n    type: {\n      control: \"select\",\n      options: [undefined, \"success\", \"error\", \"info\", \"warning\", \"loading\"],\n      description: \"Toast notification type (controls icon)\",\n    },\n    variant: {\n      control: \"select\",\n      options: [\"default\", \"outline\", \"destructive\"],\n      description: \"Visual style variant\",\n    },\n    showAction: {\n      control: \"boolean\",\n      description: \"Show action button\",\n    },\n    actionLabel: {\n      control: \"text\",\n      description: \"Action button label\",\n      if: { arg: \"showAction\", eq: true },\n    },\n    duration: {\n      control: \"number\",\n      description: \"Duration in milliseconds (0 = infinite)\",\n    },\n  },\n  args: {\n    message: \"Event has been created\",\n    description: \"\",\n    variant: \"default\",\n    showAction: false,\n    actionLabel: \"Undo\",\n    duration: 5000,\n  },\n  render: (args) => {\n    const handleClick = () => {\n      const options: ToastOptions = {\n        variant: args.variant,\n        type: args.type,\n        duration: args.duration || undefined,\n      };\n\n      if (args.description) {\n        options.description = args.description;\n      }\n\n      if (args.showAction && args.actionLabel) {\n        options.action = {\n          label: args.actionLabel,\n          onClick: () => console.log(\"Action clicked\"),\n        };\n      }\n\n      toast(args.message, options);\n    };\n\n    return html`\n      <div class=\"flex min-h-96 items-center justify-center gap-2\">\n        <ui-button @click=${handleClick}>Show Toast</ui-button>\n      </div>\n    `;\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj<ToastArgs>;\n\n/**\n * The default form of the toast.\n */\nexport const Default: Story = {};\n\n/**\n * Success toast type with icon.\n */\nexport const Success: Story = {\n  args: {\n    message: \"Operation completed successfully\",\n    description: \"Your changes have been saved.\",\n    type: \"success\",\n  },\n};\n\n/**\n * Error toast type with icon.\n */\nexport const Error: Story = {\n  args: {\n    message: \"Something went wrong\",\n    description: \"Please try again later.\",\n    type: \"error\",\n  },\n};\n\n/**\n * Warning toast type with icon.\n */\nexport const Warning: Story = {\n  args: {\n    message: \"Be careful\",\n    description: \"This action cannot be undone.\",\n    type: \"warning\",\n  },\n};\n\n/**\n * Info toast type with icon.\n */\nexport const Info: Story = {\n  args: {\n    message: \"Be at the area 10 minutes before the event time\",\n    type: \"info\",\n  },\n};\n\n/**\n * Promise toast with loading, success, and error states.\n */\nexport const PromiseToast: Story = {\n  render: () => {\n    const handleClick = () => {\n      toast.promise<{ name: string }>(\n        () =>\n          new globalThis.Promise((resolve) =>\n            setTimeout(() => resolve({ name: \"Event\" }), 2000),\n          ),\n        {\n          loading: \"Loading...\",\n          success: (data) => `${data.name} has been created`,\n          error: \"Error\",\n        },\n      );\n    };\n\n    return html`\n      <div class=\"flex min-h-96 items-center justify-center gap-2\">\n        <ui-button @click=${handleClick}>Show Promise Toast</ui-button>\n      </div>\n    `;\n  },\n};\n\n/**\n * All toast types in one demo.\n */\nexport const AllTypes: Story = {\n  render: () => {\n    return html`\n      <div class=\"flex min-h-96 flex-wrap items-center justify-center gap-2\">\n        <ui-button\n          variant=\"outline\"\n          @click=${() => toast(\"Event has been created\")}\n        >\n          Default\n        </ui-button>\n        <ui-button\n          variant=\"outline\"\n          @click=${() => toast.success(\"Event has been created\")}\n        >\n          Success\n        </ui-button>\n        <ui-button\n          variant=\"outline\"\n          @click=${() =>\n            toast.info(\"Be at the area 10 minutes before the event time\")}\n        >\n          Info\n        </ui-button>\n        <ui-button\n          variant=\"outline\"\n          @click=${() =>\n            toast.warning(\"Event start time cannot be earlier than 8am\")}\n        >\n          Warning\n        </ui-button>\n        <ui-button\n          variant=\"outline\"\n          @click=${() => toast.error(\"Event has not been created\")}\n        >\n          Error\n        </ui-button>\n        <ui-button\n          variant=\"outline\"\n          @click=${() =>\n            toast.promise<{ name: string }>(\n              () =>\n                new globalThis.Promise((resolve) =>\n                  setTimeout(() => resolve({ name: \"Event\" }), 2000),\n                ),\n              {\n                loading: \"Loading...\",\n                success: (data) => `${data.name} has been created`,\n                error: \"Error\",\n              },\n            )}\n        >\n          Promise\n        </ui-button>\n      </div>\n    `;\n  },\n};\n\n/**\n * Toast with outline variant styling.\n */\nexport const OutlineVariant: Story = {\n  args: {\n    message: \"Operation completed\",\n    description: \"Your changes have been saved.\",\n    type: \"success\",\n    variant: \"outline\",\n  },\n};\n\n/**\n * Toast with destructive variant styling.\n */\nexport const DestructiveVariant: Story = {\n  args: {\n    message: \"Critical error\",\n    description: \"This action cannot be undone.\",\n    type: \"error\",\n    variant: \"destructive\",\n  },\n};\n\n/**\n * Toast with action button.\n */\nexport const WithAction: Story = {\n  args: {\n    message: \"Event has been created\",\n    description: new Date().toLocaleString(),\n    showAction: true,\n    actionLabel: \"Undo\",\n  },\n};\n\n/**\n * Loading toast type with spinning icon.\n */\nexport const Loading: Story = {\n  args: {\n    message: \"Loading...\",\n    description: \"Please wait while we process your request.\",\n    type: \"loading\",\n    duration: 0,\n  },\n};\n"}]}