Cookbook

A modal dialog

Build a confirmation dialog on the browser's <dialog> element, and read which button closed it.

Press Delete report.pdf…, then answer with a button, or press Escape to cancel. Focus goes to Keep it when the dialog opens, and back to the button that opened it when it closes.

module Snippets.ConfirmDialog

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type ConfirmDelete() =
    inherit LitElement()

    let dialog = createRef<HTMLDialogElement> ()

    static member properties =
        PropertyDeclarations.create [ "answer", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; justify-items: start; }
        button { font: inherit; padding: 0.3rem 0.9rem; }
        p { margin: 0; }
        dialog { border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); color: var(--fg);
                 padding: 1rem 1.25rem; max-width: 20rem; }
        dialog::backdrop { background: rgb(0 0 0 / 0.4); }
        dialog h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
        .actions { display: flex; gap: 0.5rem; justify-content: end; margin-top: 1rem; }
        """

    member val answer = "" with get, set

    member this.Ask() =
        dialog.value
        |> Option.iter (fun d ->
            // Escape closes without a button, and leaves returnValue as it was: clear it first.
            d.returnValue <- ""
            d.showModal ()
        )

    member this.Closed() =
        dialog.value
        |> Option.iter (fun d ->
            this.answer <-
                match d.returnValue with
                | "delete" -> "Deleted report.pdf."
                | _ -> "Kept report.pdf."
        )

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Ask()}>Delete report.pdf…</button>
        <p role="status">{if this.answer <> "" then html $"{this.answer}" else nothing}</p>
        <dialog {ref dialog} aria-labelledby="confirm-title" @close={fun _ -> this.Closed()}>
            <form method="dialog">
                <h2 id="confirm-title">Delete report.pdf?</h2>
                <p>You can't undo this.</p>
                <div class="actions">
                    <button value="keep" autofocus>Keep it</button>
                    <button value="delete">Delete</button>
                </div>
            </form>
        </dialog>"""

defineElement<ConfirmDelete> "my-confirm-delete"

How it works

The browser does most of the work. showModal () opens the dialog above everything else, makes the rest of the page inert, so Tab stays inside the dialog, and closes it on Escape. When it closes, the focus returns to the element that had it before.

The dialog lives in the component's shadow root, so the component's styles reach it, including ::backdrop. A closed <dialog> isn't displayed, so the prerendered HTML shows only the button.

To close the dialog when the backdrop is clicked too, add closedby="any" to it. Browsers that don't support the attribute ignore it, and Escape and the buttons still work.