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.
- Fallback content. What's inside a
<slot>shows when nothing is assigned to it, as "Untitled" does. - Styling slotted elements.
::slotted(...)styles the page's elements from inside the component. The slotted heading takesfont: inherit, so it looks the same as the "Untitled" fallback, which the header styles;.body ::slotted(*)removes the body elements' margins, and the body's grid gap spaces them instead; and::slotted(button)gives the footer's button a border and a background. These rules reach only the top-level children, and the page's own rules win. - The page's colours. Custom properties such as
--borderand--surfaceinherit into the shadow root, so the card and its button follow the page's light and dark themes. - An empty footer. A slot can't hide the element around it, so the component checks the slot
with
assignedElementsand setshasFooter.?hiddenhides the footer, border and all, while it's empty.
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.
Related
- Styling: Component styles, for
::slottedand:host. - From Lit: @queryAssignedElements, for counting slotted elements.