Toast notifications
Build toast notifications that any code on the page can raise, from F# or JavaScript, and that one component shows.
Press Save, a separate component, or the page's own button, which runs plain JavaScript. Each toast goes after five seconds, or when you dismiss it.
module Snippets.Toasts
open Fable.Core
open Browser.Dom
open Browser.Types
open Firelight
open type Firelight.Lit
/// Shows a toast. Any code can call it, and JavaScript can raise the same `toast` event.
let showToast (message: string) =
document.dispatchEvent (Event.customEvent ("toast", message)) |> ignore
type Toast = { Id: int; Message: string }
/// Shows the toasts raised anywhere on the page, each for five seconds. Put one on the page.
[<AttachMembers>]
type Toaster() =
inherit LitElement()
let mutable nextId = 0
let mutable timers: Map<int, float> = Map.empty
let mutable stopListening = ignore
/// Where the focus was before it moved onto a toast, to put it back when the last one goes.
let mutable cameFrom: HTMLElement option = None
static member properties =
PropertyDeclarations.create [ "toasts", PropertyDeclaration<Toast list>(state = true) ]
static member styles =
css
$$"""
:host { display: block; }
.stack { display: grid; gap: 0.5rem; justify-items: start; }
.toast { display: flex; gap: 0.75rem; align-items: center; padding: 0.5rem 0.5rem 0.5rem 1rem;
border-radius: 0.5rem; background: var(--fg); color: var(--bg); }
button { font: inherit; padding: 0 0.5rem; border: 0; border-radius: 0.25rem; background: none;
color: inherit; cursor: pointer; }
button:focus-visible { outline: 2px solid var(--bg); }
"""
member val toasts: Toast list = [] with get, set
member this.Add(message: string) =
nextId <- nextId + 1
this.toasts <- this.toasts @ [ { Id = nextId; Message = message } ]
this.Schedule nextId
member this.Schedule(id: int) =
timers <- timers.Add(id, window.setTimeout ((fun () -> this.Expire id), 5000))
/// Time's up, unless the focus is on a toast: then wait another five seconds.
member this.Expire(id: int) =
if isNull this.shadowRoot.activeElement then
this.Remove id
else
this.Schedule id
member this.Remove(id: int) =
timers.TryFind id |> Option.iter window.clearTimeout
timers <- timers.Remove id
this.toasts <- this.toasts |> List.filter (fun t -> t.Id <> id)
/// Dismissed from its button: the focus moves to the next toast, or back where it came from.
member this.Dismiss(id: int) =
let index = this.toasts |> List.findIndex (fun t -> t.Id = id)
this.Remove id
let next = this.toasts |> List.tryItem (min index (this.toasts.Length - 1))
promise {
let! _ = this.updateComplete
match next with
| Some toast -> this.query<HTMLElement> $"#dismiss-{toast.Id}" |> Option.iter _.focus()
| None -> cameFrom |> Option.iter (fun el -> el.focus ())
}
|> Promise.start
member this.FocusIn(e: FocusEvent) =
match e.relatedTarget with
| :? Node as from when not (this.shadowRoot.contains from) -> cameFrom <- Some(from :?> HTMLElement)
| _ -> ()
override this.connectedCallback() =
base.connectedCallback ()
stopListening <-
Ev.listen document "toast" (Ev.custom<string> (fun e -> e.detail |> Option.iter this.Add))
override this.disconnectedCallback() =
base.disconnectedCallback ()
stopListening ()
timers |> Map.iter (fun _ handle -> window.clearTimeout handle)
timers <- Map.empty
this.toasts <- []
member this.ToastView(toast: Toast) =
html
$"""
<div class="toast">
{toast.Message}
<button id="dismiss-{toast.Id}" aria-label="Dismiss" @click={fun _ -> this.Dismiss toast.Id}>×</button>
</div>"""
override this.render() =
// One live region, on the page from the start, so screen readers announce each new toast.
// aria-atomic="false": announce the toast that was added, not the whole stack again.
html
$"""
<div class="stack" role="status" aria-atomic="false" @focusin={Ev.focus this.FocusIn}>
{repeat (this.toasts, (fun t -> t.Id), this.ToastView)}
</div>"""
/// A component far from the toaster, which raises toasts without knowing where it is.
[<AttachMembers>]
type SaveButton() =
inherit LitElement()
static member styles = css $$"""button { font: inherit; padding: 0.3rem 0.9rem; }"""
override _.render() =
html $"""<button @click={fun _ -> showToast "Saved."}>Save</button>"""
defineElement<Toaster> "my-toaster"
defineElement<SaveButton> "my-save-button"
How it works
The toaster and the code that raises a toast don't know about each other. showToast dispatches a
toast event on document, with the message as its detail, and the toaster listens there. Any
F# code can call showToast, and any script can dispatch the same event, as the page does.
- Listening. The toaster adds its listener in
connectedCallbackand removes it indisconnectedCallback, keeping the very function it added, as Events: Listen outside the component explains. - Timers. Each toast has a
setTimeoutto remove it, kept in a map by the toast's id. Dismissing a toast clears its timer, and leaving the page clears them all, so nothing runs on a removed component. While the focus is on a toast, a timer that runs out starts again instead, so a toast never disappears from under the keyboard. - The focus. Removing the element that has the focus sends the focus to the page's start. So
Dismiss moves it to the next toast's button, or, after the last one, back to where it was before
it moved onto a toast, which
@focusinnoted from the event'srelatedTarget. - Announcing. The toasts render inside one
role="status"element, which is on the page from the start. Screen readers announce what's added to it without moving the focus; a live region added along with its message is often not announced at all. A status region announces all of its content by default, soaria-atomic="false"limits that to the new toast. - Keyed.
repeatkeys each toast by its id, so dismissing one doesn't move the others' messages between elements.
In this demo the toaster sits in the flow. In an app, put one <my-toaster> at the end of
<body>, and fix it to a corner from the page's CSS: my-toaster { position: fixed; inset: auto 1rem 1rem auto; }.
Five seconds suits a confirmation such as "Saved". A message that someone must read or act on, such as an error, shouldn't disappear on a timer: show it where it happened, or keep it until it's dismissed.
An event suits messages anyone can raise. When the toasts are state that several components read, such as a count in the header, keep them in a signal instead.
Related
- Component communication, for events and signals between components.