Cookbook

A card with slots

Build a card that takes its heading, body and footer from the page through named slots, and hides its footer when there's nothing in it.

The first card fills all three slots. The second gives only a body: its heading falls back to "Untitled", and its footer is hidden.

module Snippets.SlotCard

open Fable.Core
open Firelight
open type Firelight.Lit

/// A card with a heading, a body and an optional footer, all supplied by the page.
[<AttachMembers>]
type SlotCard() =
    inherit LitElement()

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

    static member styles =
        css
            $$"""
        :host { display: block; align-self: start; width: 16rem; border: 1px solid var(--border);
                border-radius: var(--radius); background: var(--bg); }
        header, .body, footer { padding: 0.75rem 1rem; }
        /* The heading looks the same from the slot as from the fallback: the slotted one inherits. */
        header { border-bottom: 1px solid var(--border); font-size: 1.1rem; font-weight: 600; }
        ::slotted([slot="heading"]) { margin: 0; font: inherit; }
        /* The body's elements lose their own margins; the grid's gap spaces them instead. */
        .body { display: grid; gap: 0.5rem; }
        .body ::slotted(*) { margin: 0; }
        footer { display: flex; gap: 0.5rem; justify-content: end; border-top: 1px solid var(--border); }
        footer[hidden] { display: none; }
        ::slotted(button) { font: inherit; color: var(--fg); background: var(--surface); padding: 0.3rem 0.9rem;
                            border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; }
        ::slotted(button:hover) { border-color: var(--muted); }
        ::slotted(button:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
        """

    member val hasFooter = false with get, set

    member this.CheckFooter(slot: HTMLSlotElement) =
        this.hasFooter <- slot.assignedElements().Length > 0

    // slotchange reports changes, but a prerendered card's elements were in their slots before
    // this code ran, so check once as well. After updateComplete, not in firstUpdated itself:
    // setting hasFooter during an update schedules another, which Lit warns about.
    override this.firstUpdated _ =
        promise {
            let! _ = this.updateComplete
            this.query<HTMLSlotElement> "slot[name=footer]" |> Option.iter this.CheckFooter
        }
        |> Promise.start

    override this.render() =
        html
            $"""
        <header><slot name="heading">Untitled</slot></header>
        <div class="body"><slot></slot></div>
        <footer ?hidden={not this.hasFooter}>
            <slot name="footer" @slotchange={Ev.slot this.CheckFooter}></slot>
        </footer>"""

defineElement<SlotCard> "my-slot-card"

Weekly report

Sales are up 4% on last week.

No heading and no footer.

How it works

A <slot> in a component's template shows the elements the page put inside the component's tag. A child with slot="heading" goes to <slot name="heading">, and the rest go to the slot with no name. The children stay in the page's DOM: the page's styles and listeners apply to them, and the page picks the elements, such as which heading level fits where the card sits.

Ev.slot hands CheckFooter the <slot> that @slotchange is bound on, as an HTMLSlotElement, so assignedElements needs no cast. slotchange reports changes, but in a prerendered card the page's elements were in their slots before the component's code ran, and no event reports them. So firstUpdated checks once as well, finding the same slot with this.query<HTMLSlotElement> "slot[name=footer]". It waits for updateComplete in a promise { } first: setting hasFooter during the first update would schedule a second one straight away, which Lit warns about.

The cards are prerendered, and the page's elements are already in the page, so the first card shows its heading and body before any JavaScript runs. Its footer appears only once firstUpdated has looked, because render at build time can't see which slots the page fills, so the card grows and moves what's below it. CSS can't make that check yet: the :has-slotted pseudo-class would hide an empty footer without script, but only Firefox supports it. If most of your cards have a footer, start hasFooter as true: the usual card then doesn't move, and a card without a footer shrinks instead.