Packages

Firelight.Observers

Reactive controllers for the browser's resize, intersection, mutation and performance observers.

Why use it

The browser's observers report things a component often needs to know: its own size, whether it's on screen, changes to its children. Using them directly means creating the observer when the component connects, disconnecting it when it's removed, and asking Lit to render when something changes.

Each controller here does that work. Give it a callback that turns the observer's entries into a value, then read .value in render. CSS container queries can restyle a component by its size; a ResizeController can also change what it renders.

Example

The panel picks its layout from its own width, not the window's. Drag its bottom-right corner to resize it.

module Snippets.ResizePanel

open Fable.Core
open Firelight
open Firelight.Observers
open type Firelight.Lit

/// Lays itself out by its own width, not the window's. Drag the bottom-right corner to resize it.
[<AttachMembers>]
type ResizePanel() as this =
    inherit LitElement()

    let width =
        ResizeController<float>(
            this,
            ResizeControllerConfig(
                callback =
                    ResizeValueCallback(fun entries _ ->
                        if entries.Length = 0 then
                            0.0
                        else
                            entries.[0].contentRect.width
                    )
            )
        )

    static member styles =
        css
            $$"""
        :host {
            display: block; resize: horizontal; overflow: auto; box-sizing: border-box; width: 100%;
            min-width: 10rem; max-width: 100%; padding: 1rem;
            border: 1px dashed var(--border); border-radius: 0.5rem;
        }
        .boxes { display: grid; gap: 0.5rem; }
        .wide .boxes { grid-template-columns: repeat(3, 1fr); }
        .box { padding: 0.75rem; border-radius: 0.5rem; background: var(--accent); color: var(--accent-fg); text-align: center; }
        """

    override _.render() =
        let px = width.value |> Option.defaultValue 0.0
        let layout = if px >= 300.0 then "wide" else "narrow"

        html
            $"""
        <div class={layout}>
            <p>{int px}px wide, so the <strong>{layout}</strong> layout</p>
            <div class="boxes"><div class="box">One</div><div class="box">Two</div><div class="box">Three</div></div>
        </div>"""

defineElement<ResizePanel> "my-resize-panel"

Observe an element in the template

A controller observes its host unless told otherwise. To observe an element the component renders, give the controller target = null and mark the element with a hole in its opening tag: <div {controller.target ()}>. The element is observed while it's rendered. Scroll the box until the marker shows:

module Snippets.ScrollMarker

open Fable.Core
open Firelight
open Firelight.Observers
open type Firelight.Lit

/// Says whether the marker inside its scrolling box is in view.
[<AttachMembers>]
type ScrollMarker() as this =
    inherit LitElement()

    // target = null: observe only the element that target () marks in the template, not the host.
    let marker =
        IntersectionController(
            this,
            IntersectionControllerConfig(
                target = null,
                callback = IntersectionValueCallback(fun entries _ -> entries |> Array.exists _.isIntersecting)
            )
        )

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; }
        p { margin: 0; }
        .box { height: 7rem; overflow-y: auto; border: 1px solid var(--border, #ccc); border-radius: 0.5rem; padding: 0 0.75rem; }
        .spacer { height: 14rem; padding-top: 0.5rem; }
        .marker { margin-bottom: 0.75rem; padding: 0.4rem 0.75rem; border-radius: 0.4rem; background: #1971c2; color: white; }
        """

    override _.render() =
        let inView = marker.value = Some true

        html
            $"""
        <p role="status">The marker is {if inView then "in view" else "out of view"}.</p>
        <div class="box" tabindex="0" aria-label="Scrolling box">
            <div class="spacer">Scroll down.</div>
            <div class="marker" {marker.target ()}>Marker</div>
        </div>"""

defineElement<ScrollMarker> "my-scroll-marker"

ResizeController.target is Lit's own directive. @lit-labs/observers has none for IntersectionController, so Firelight's is Lit's ref with one callback per controller: it observes the element a render puts there, and stops observing one a render removes. That makes it one element per controller. Put target () on two, and Lit moves the ref between them on every render, so only the last is observed, and the component renders again and again. For several elements, give each its own controller.

Install

dotnet add package Firelight.Observers
npm install @lit-labs/observers