Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,25 @@ Custom toast components can also dismiss themselves with the composable client-s

Both dismissal paths run the exit animation before removing the toast.

### Browser JavaScript

Import `addToast` when browser JavaScript needs to request a toast. The mounted toast host validates the requested kind
and renders the toast server-side, so it continues to use the host's configured styles, custom component function,
animation, and stacking behavior.

```javascript
import { addToast } from 'live_toast'

addToast('info', 'Copied to clipboard.', {
title: 'Copied',
duration: 3_000,
metadata: { has_icon: false }
})
```

The browser API accepts serializable `title`, `duration`, and `metadata` options. Use `duration: 'infinity'` for a
persistent toast.

Or you can use the helper function, [`put_toast`](https://hexdocs.pm/live_toast/LiveToast.html#put_toast/4), similar to how you may use [`put_flash`](https://hexdocs.pm/phoenix/Phoenix.Controller.html#put_flash/3):

```elixir
Expand Down
14 changes: 13 additions & 1 deletion assets/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,16 @@ import type { ViewHook } from '../deps/phoenix_live_view'

declare const createLiveToastHook: () => ViewHook

export { createLiveToastHook }
type ClientToastOptions = {
duration?: number | 'infinity'
metadata?: Record<string, unknown>
title?: string
}

declare function addToast(
kind: string,
message: string,
options?: ClientToastOptions
): void

export { addToast, ClientToastOptions, createLiveToastHook }
55 changes: 55 additions & 0 deletions assets/js/live_toast/live_toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const maxItemsIgnoresFlashes = true
// gap in px between toasts
const gap = 15
const dismissEvent = 'live-toast-dismiss'
const clientToastEvent = 'live-toast:add'
const remainingSelector = '[data-live-toast-remaining]'

let lastTS: HTMLElement[] = []
Expand All @@ -67,6 +68,18 @@ type DismissTimer = {
cancel: () => void
}

export type ClientToastOptions = {
duration?: number | 'infinity'
metadata?: Record<string, unknown>
title?: string
}

type ClientToastRequest = {
kind: string
message: string
options: ClientToastOptions
}

const dismissTimers = new WeakMap<object, DismissTimer>()

declare global {
Expand All @@ -76,6 +89,20 @@ declare global {
}
}

export function addToast(
kind: string,
message: string,
options: ClientToastOptions = {}
) {
document
.getElementById('toast-group')
?.dispatchEvent(
new CustomEvent<ClientToastRequest>(clientToastEvent, {
detail: { kind, message, options }
})
)
}

function doAnimations(
this: ViewHook,
animationDelayTime: number,
Expand Down Expand Up @@ -349,16 +376,44 @@ function startDismissTimer(
export function createLiveToastHook(duration = 6000, maxItems = 3) {
return {
destroyed(this: ViewHook) {
if (this.el.dataset.liveToastGroup === 'true') {
return
}

dismissTimers.get(this)?.cancel()
dismissTimers.delete(this)
doAnimations.bind(this)(duration, maxItems)
},
updated(this: ViewHook) {
if (this.el.dataset.liveToastGroup === 'true') {
return
}

// animate to targetDestination in 0ms
const keyframes = { y: [this.el.targetDestination] }
animate(this.el, keyframes, { duration: 0 })
},
mounted(this: ViewHook) {
if (this.el.dataset.liveToastGroup === 'true') {
const clientToastListener = (event: Event) => {
const request = (event as CustomEvent<ClientToastRequest>).detail

if (!request) {
return
}

this.pushEventTo(this.el, 'add_toast', {
kind: request.kind,
message: request.message,
options: request.options
})
}

this.el.addEventListener(clientToastEvent, clientToastListener)

return
}

this.el.addEventListener('show-error', async _event => {
const delayTime = Number.parseInt(this.el.dataset.delay || '0')
await new Promise(resolve => setTimeout(resolve, delayTime))
Expand Down
60 changes: 59 additions & 1 deletion assets/test/live_toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ mock.module('motion', () => ({
}
}))

const { createLiveToastHook } = await import('../js/live_toast/live_toast.ts')
const { addToast, createLiveToastHook } = await import('../js/live_toast/live_toast.ts')

type MountedToast = ReturnType<typeof mountToast>

Expand Down Expand Up @@ -119,6 +119,28 @@ function mountToast(duration: number | 'Infinity' = 1000, countdown = false) {
}
}

function mountToastGroup() {
document.body.insertAdjacentHTML(
'beforeend',
'<div id="toast-group" phx-hook="LiveToast" data-live-toast-group="true"></div>'
)

const el = document.getElementById('toast-group') as HTMLElement
const pushes: Array<[string, Record<string, unknown>]> = []
const callbacks = createLiveToastHook()
const hook = {
el,
pushEvent: () => undefined,
pushEventTo: (_target: Element | string, event: string, payload: Record<string, unknown>) => {
pushes.push([event, payload])
}
}

callbacks.mounted.call(hook as never)

return { callbacks, el, hook, pushes }
}

function advance(milliseconds: number) {
now += milliseconds
jest.advanceTimersByTime(milliseconds)
Expand Down Expand Up @@ -231,4 +253,40 @@ describe('LiveToast timed dismissal', () => {

})

describe('LiveToast client API', () => {
beforeEach(() => {
installDom()
})

test('routes a browser toast request to the default host', () => {
const toastGroup = mountToastGroup()

addToast('info', 'Copied to clipboard', {
duration: 3_000,
metadata: { has_icon: false },
title: 'Copied'
})

expect(toastGroup.pushes).toEqual([
[
'add_toast',
{
kind: 'info',
message: 'Copied to clipboard',
options: {
duration: 3_000,
metadata: { has_icon: false },
title: 'Copied'
}
}
]
])
})

test('does nothing when the requested host is not mounted', () => {
expect(() => addToast('info', 'Copied to clipboard')).not.toThrow()
})

})

const remainingSelector = '[data-live-toast-remaining]'
22 changes: 21 additions & 1 deletion demo/assets/js/app.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import { createLiveToastHook } from '../../../assets/js/live_toast/live_toast.ts'
import {
addToast,
createLiveToastHook
} from '../../../assets/js/live_toast/live_toast.ts'

let csrfToken = document
.querySelector("meta[name='csrf-token']")
Expand All @@ -23,6 +26,23 @@ window.addEventListener('phx:close-menu', (e) => {
backdrop.classList.remove('max-md:block')
})

document.addEventListener('click', event => {
if (!(event.target instanceof Element)) {
return
}

const trigger = event.target.closest('[data-client-toast-example]')

if (!trigger) {
return
}

addToast('info', 'This toast was requested from browser JavaScript.', {
metadata: { source: 'demo' },
title: 'Client-side toast'
})
})

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
Expand Down
6 changes: 6 additions & 0 deletions demo/lib/demo_web/live/home_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@
>
Dismiss Programmatically
</.link>
<.link
href={~p"/recipes#client-side-toasts"}
class="block mb-1 text-sm text-zinc-600 px-2 py-0.5 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors"
>
Client-Side Toasts
</.link>
<.link
href={~p"/recipes#pause-timed-toast"}
class="block mb-1 text-sm text-zinc-600 px-2 py-0.5 hover:text-zinc-900 hover:bg-zinc-100 rounded-md transition-colors"
Expand Down
29 changes: 29 additions & 0 deletions demo/lib/demo_web/live/tabs/recipes.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,35 @@
</div>
</div>

<div class="mb-12 space-y-4">
<h3 id="client-side-toasts" class="scroll-mt-20 text-zinc-900 font-medium">
Client-Side Toasts
</h3>
<p>
Import <code>addToast</code>
to request a toast from browser JavaScript. The mounted toast host validates the kind
and renders it server-side, so it still uses the configured classes, component function, animations, and stacking.
</p>
<pre class="overflow-x-auto rounded-md bg-zinc-950 p-4 text-sm text-zinc-100"><code>import &#123; addToast &#125; from "live_toast"

addToast("info", "Copied to clipboard", &#123;
title: "Copied",
duration: 3_000,
metadata: &#123; has_icon: false &#125;
&#125;)</code></pre>
<div class="flex flex-wrap gap-3">
<.button type="button" data-client-toast-example>Show Client-Side Toast</.button>
</div>
<p>
<.link
class="text-blue-700 hover:text-blue-500 underline"
href="https://github.com/srcrip/live_toast/blob/main/demo/assets/js/app.js"
>
View the demo source
</.link>
</p>
</div>

<div class="mb-12 space-y-4">
<h3 id="dismiss-programmatically" class="scroll-mt-20 text-zinc-900 font-medium">
Dismiss Programmatically
Expand Down
9 changes: 9 additions & 0 deletions demo/test/demo_web/live/home_live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ defmodule DemoWeb.HomeLiveTest do
assert html =~ "Dismiss From Server"
end

test "renders client-side toast recipe", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/recipes")

assert html =~ "Client-Side Toasts"
assert html =~ "Show Client-Side Toast"
assert html =~ "addToast"
end

test "renders pause timed toast recipe", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/recipes")

Expand Down Expand Up @@ -179,6 +187,7 @@ defmodule DemoWeb.HomeLiveTest do
assert html =~ ~s(href="/#duration")
assert html =~ ~s(href="/recipes#showing-progress")
assert html =~ ~s(href="/recipes#dismiss-programmatically")
assert html =~ ~s(href="/recipes#client-side-toasts")
assert html =~ ~s(href="/recipes#pause-timed-toast")
assert html =~ ~s(href="/recipes#centered-positions")
assert html =~ ~s(href="/recipes#persistent-toasts")
Expand Down
Loading
Loading