# Web Components for F# Source: https://roboz0r.github.io/Firelight/ Firelight is a set of F# bindings for [Lit](https://lit.dev/), compiled with [Fable](https://fable.io/). Write type-safe components with reactive properties, templates and the Elmish loop, and ship standard custom elements that work in any page. - [Get started](https://roboz0r.github.io/Firelight/start/) - [View on GitHub](https://github.com/roboz0r/Firelight) ## A component in a few lines The code below is the source file for the live component beside it. This site is built with Firelight. ```fsharp module Snippets.Counter open Fable.Core open Firelight open type Firelight.Lit [] type Counter() = inherit LitElement() static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() ] static member styles = css $$""" button { font: inherit; padding: 0.5rem 1rem; cursor: pointer; } """ member val count = 0 with get, set override this.render() = html $""" """ defineElement "my-counter" ``` ```html ``` ### Why a class? You write a component as an F# class with mutable properties, which can look out of place in F#. But a custom element is a class: the browser creates it, calls its lifecycle methods and sets its properties, and Lit's components extend `LitElement`. Firelight keeps that mapping direct: a component is an ordinary F# class. - **What you write is what runs.** There's no Firelight-specific compiler plugin or build step: `[]` tells Fable to put the members on the JavaScript class under their own names, where Lit looks for them. - **Lit's features map directly.** Lifecycle callbacks, controllers and shadow DOM options are members you override or call on the class, so [Lit's documentation](https://lit.dev/docs/components/overview/) for them carries over. - **The class is a thin shell.** Changing `this.count`, a declared reactive property, schedules a render. Keep domain logic in functions and state in immutable values; the next example moves the state into an Elmish loop. ## The Elmish loop, inside a component `ElmishController` runs Model-View-Update inside a component. The update function stays pure and every new model triggers a render. ```fsharp module Snippets.ElmishCounter open Fable.Core open Firelight open Firelight.Elmish open type Firelight.Lit type Model = { Count: int } type Msg = | Increment | Decrement let init () = { Count = 0 } let update msg model = match msg with | Increment -> { model with Count = model.Count + 1 } | Decrement -> { model with Count = model.Count - 1 } [] type ElmishCounter() as this = inherit LitElement() let elmish = ElmishController.simple this init update static member styles = css $$""" :host { display: inline-flex; align-items: center; gap: 0.75rem; } button { font: inherit; width: 2.5rem; height: 2.5rem; cursor: pointer; } """ override _.render() = html $""" {elmish.model.Count} """ defineElement "my-elmish-counter" ``` ```html ``` ## Why Firelight ### Built on web standards Components are native custom elements with Shadow DOM. Nothing takes over the page, so you can use them in plain HTML or alongside any other framework. ### Idiomatic F# Records, discriminated unions and pattern matching drive the UI. Immutable models mean every change is seen by Lit's change detection. ### Works with other web components Libraries like Web Awesome, Fluent UI and Carbon are also web components, so you use their tags directly in your templates without wrappers. ### No runtime of its own The core bindings compile away: your components import Lit directly. The [Todo demo](https://roboz0r.github.io/Firelight/demos/todo/), Lit included, ships 24.2 kB of gzipped JavaScript. [Firelight compared](https://roboz0r.github.io/Firelight/why/) sets it beside Fable.Lit, Feliz, Sutil and Lit in TypeScript. ## Packages Start with `Firelight` and add the others as you need them. Each page explains when you'd want the package and has a live example. | Package | What it does | | --- | --- | | [Firelight](https://roboz0r.github.io/Firelight/packages/firelight/) | Core bindings: `LitElement`, `html`/`css` templates, directives, reactive properties | | [Firelight.Context](https://roboz0r.github.io/Firelight/packages/context/) | Share state across a component tree without passing it through every layer | | [Firelight.Elmish](https://roboz0r.github.io/Firelight/packages/elmish/) | Run an Elmish (Model-View-Update) loop inside a component | | [Firelight.Router](https://roboz0r.github.io/Firelight/packages/router/) | Client-side routing on the URL Pattern API, with routes as an F# union | | [Firelight.Signals](https://roboz0r.github.io/Firelight/packages/signals/) | Shared reactive state that any component can read | | [Firelight.Motion](https://roboz0r.github.io/Firelight/packages/motion/) | Animate elements as they move, appear and disappear | | [Firelight.Observers](https://roboz0r.github.io/Firelight/packages/observers/) | Reactive controllers for resize, intersection, mutation and performance observers | | [Firelight.Task](https://roboz0r.github.io/Firelight/packages/task/) | Async work with pending, complete and error states | | [Firelight.Virtualizer](https://roboz0r.github.io/Firelight/packages/virtualizer/) | Long lists that only render the visible items | ## Demo apps ### [Todo](https://roboz0r.github.io/Firelight/demos/todo/) An Elmish loop in the root component, with state and dispatch shared through context. 24.2 kB of gzipped JavaScript. ### [Kanban](https://roboz0r.github.io/Firelight/demos/kanban/) A board with drag and drop between columns, an edit dialog and Tailwind styles, saved to local storage as you go. 34.1 kB of gzipped JavaScript. ### [Client-side routing](https://roboz0r.github.io/Firelight/client-side-routing/) URL patterns matched to an F# route type with `Firelight.Router`, and why the rest of this site doesn't use it. ## Get started Add the package to a Fable project, and install Lit from npm: ```sh dotnet add package Firelight npm install lit ``` New to Firelight? The [Get started](https://roboz0r.github.io/Firelight/start/) tutorial creates an app from a template, then gives its component an attribute, an event, styles and a list.

Firelight hasn't reached 1.0 yet, so its API may still change before then.

# Your first component Source: https://roboz0r.github.io/Firelight/start/ From an empty folder to a web component written in F# and running in your browser. Then four small changes to it: an attribute, an event, styles and a list. A Firelight component is a standard custom element. You write it in F#, the [Fable](https://fable.io/) compiler turns it into JavaScript, and Lit runs it in the browser. A `dotnet new` template sets all of that up. ## What you need - The [.NET 10 SDK](https://dotnet.microsoft.com/download) or later, for F# and the Fable compiler. - [Node.js](https://nodejs.org/) 22.12 or later (20.19 or later on Node 20), for Lit and the Vite dev server. Nothing runs on .NET in the finished app. What you ship is HTML and JavaScript. ## Create an app Install the template, then create an app called MyApp from it: ```sh dotnet new install Firelight.Templates dotnet new firelight -n MyApp cd MyApp ``` Then install the Fable compiler and the npm packages, and start the dev server: ```sh dotnet tool restore npm install npm run dev ``` `npm run dev` compiles your F# to JavaScript, then starts Vite, which prints a local address (`http://localhost:5173/` unless that port is taken). Open it: you'll see the heading "MyApp" and a button that counts your clicks. Leave it running. When you save an F# file, Fable recompiles it and the page reloads. ## What's in it | File | What it's for | | --- | --- | | `App.fs` | The component, in F#. | | `MyApp.fsproj` | The F# project: its source files, and the `Firelight` package. Fable reads it to know what to compile. | | `index.html` | The page. It uses the component as `` and loads `build/App.js`, which Fable compiles from `App.fs`. | | `package.json` | The npm side: Lit, Vite, and the `dev`, `build` and `preview` scripts. | | `vite.config.js` | Settings for Vite, which serves the page while you work and bundles it for production. | | `.config/dotnet-tools.json` | The version of the Fable compiler, which `dotnet tool restore` installs. | There's also a `README.md` and a `.gitignore`. Fable writes its JavaScript to `build/`, and the production build goes to `dist/`. ## The component `App.fs` defines one component: a button that counts clicks. Here it is, running. ```fsharp module MyApp.App open Fable.Core open Firelight open type Firelight.Lit [] type ClickCounter() = inherit LitElement() // Changing a reactive property re-renders the component. static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() ] // Styles live in the component's shadow DOM, so they apply only to this component. static member styles = css $$""" button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; } """ member val count = 0 with get, set override this.render() = html $""" """ defineElement "click-counter" ``` ```html ``` From the top: - `ClickCounter` inherits `LitElement`, Lit's base class for components. `[]` tells Fable to compile its members onto the JavaScript class, where Lit looks for them. - `properties` declares `count` as a reactive property: setting it re-renders the component. `member val count = 0` holds its value, starting at 0. - `styles` is the component's CSS. There's more on styles [below](https://roboz0r.github.io/Firelight/start/#style-it). - `render` returns the component's HTML, written as an F# interpolated string. `{this.count}` inserts a value, and `@click={...}` makes an F# function the click handler. When `count` changes, Lit updates only the text that shows it. - `defineElement` registers the class as the `` element, which is how `index.html` can use it. `css` strings start with `$$"""`, so that single braces are CSS; to insert an F# value there, write `{{value}}`. `html` strings start with `$"""`, so `{...}` inserts a value. The steps below each change `App.fs` and show the whole file, running. Two lines differ from yours: all the steps are compiled together and run on this one page, so each needs its own module name (the first line) and tag (the last). Keep `module MyApp.App` and `"click-counter"` in your copy. ## Set it from an attribute HTML elements are configured with attributes, and components can be too. Lit links each reactive property to an attribute of the same name, so a `label` property can be set from the page. Add it to `properties`, give it a default with `member val`, and show it in the template: ```fsharp module MyApp.Label open Fable.Core open Firelight open type Firelight.Lit [] type ClickCounter() = inherit LitElement() // Changing a reactive property re-renders the component. static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() "label", PropertyDeclaration() ] // Styles live in the component's shadow DOM, so they apply only to this component. static member styles = css $$""" button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; } """ member val count = 0 with get, set member val label = "Clicked" with get, set override this.render() = html $""" """ defineElement "click-counter-label" ``` ```html ``` Then set it in `index.html`: ```html ``` Without the attribute, `label` keeps its default, as in the second counter above. Each element has its own state, so the two count separately. Attribute values are text. For a number or a Boolean, declare the property's type so Lit converts the attribute, as the [star rating](https://roboz0r.github.io/Firelight/packages/firelight/#example) does for its `value`. ## Tell the page when it changes Properties and attributes carry data into a component. To send data out, a component raises a DOM event, just as built-in elements do. Anything on the page can listen, with no reference to your F# code. Here, each click raises a `count-changed` event with the new count as its `detail`: ```fsharp module MyApp.Events open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ClickCounter() = inherit LitElement() // Changing a reactive property re-renders the component. static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() "label", PropertyDeclaration() ] // Styles live in the component's shadow DOM, so they apply only to this component. static member styles = css $$""" button { font: inherit; padding: 0.5rem 1rem; border-radius: 0.5rem; cursor: pointer; } """ member val count = 0 with get, set member val label = "Clicked" with get, set member this.Increment() = this.count <- this.count + 1 // The event bubbles out of the shadow DOM, so the page can listen for it. this.dispatch (Event.customEvent ("count-changed", this.count)) override this.render() = html $""" """ defineElement "click-counter-events" ``` ```html

The page hasn't heard from the counter yet.

``` The click handler now calls `Increment`, which updates `count` and then dispatches the event. `Event.customEvent` (from `open Browser.Types`) creates an event that bubbles and crosses the shadow DOM boundary, so listeners outside the component hear it. The message under the counter comes from plain JavaScript on this page. To do the same, add this to `index.html`, below the counter: ```html

The page hasn't heard from the counter yet.

``` An F# component that renders the counter listens in its template, with an `@count-changed` binding. `Ev.custom` tells F# that the event's `detail` is an `int`; it arrives as an `int option`, since an event may have none: ```fsharp fragment html $""" (fun e -> this.heard <- e.detail)}>""" ``` ## Style it A component's `styles` apply inside its shadow DOM, and only there. Its rules don't reach the rest of the page, and the page's rules don't reach in, so the component looks the same wherever it's used. Inherited properties such as `font` and `color` do pass through, and so do CSS custom properties, which is how a page can theme a component. This version gives the button a colour, read from a `--counter-color` custom property with `rebeccapurple` as the default: ```fsharp module MyApp.Styles open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ClickCounter() = inherit LitElement() // Changing a reactive property re-renders the component. static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() "label", PropertyDeclaration() ] // Styles live in the component's shadow DOM, so they apply only to this component. static member styles = css $$""" button { font: inherit; padding: 0.5rem 1rem; border: none; border-radius: 2rem; cursor: pointer; color: white; background: var(--counter-color, rebeccapurple); } """ member val count = 0 with get, set member val label = "Clicked" with get, set member this.Increment() = this.count <- this.count + 1 // The event bubbles out of the shadow DOM, so the page can listen for it. this.dispatch (Event.customEvent ("count-changed", this.count)) override this.render() = html $""" """ defineElement "click-counter-styles" ``` ```html ``` The component's `button` rule styles only its own button. The third button belongs to the page, so the rule doesn't touch it. The second counter sets `--counter-color` in its `style` attribute, and that does reach the button inside. Try both in `index.html`: ```html ``` ## Render a list A template can contain a list of templates, so repeated markup comes from F# data and the list functions you already use. Here, a row of buttons comes from a list of step sizes: ```fsharp module MyApp.List open Fable.Core open Browser.Types open Firelight open type Firelight.Lit let steps = [ 1; 10; 100 ] [] type ClickCounter() = inherit LitElement() // Changing a reactive property re-renders the component. static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() "label", PropertyDeclaration() ] // Styles live in the component's shadow DOM, so they apply only to this component. static member styles = css $$""" :host { display: inline-flex; align-items: center; gap: 0.5rem; } button { font: inherit; padding: 0.5rem 1rem; border: none; border-radius: 2rem; cursor: pointer; color: white; background: var(--counter-color, rebeccapurple); } """ member val count = 0 with get, set member val label = "Clicked" with get, set member this.Add(step: int) = this.count <- this.count + step // The event bubbles out of the shadow DOM, so the page can listen for it. this.dispatch (Event.customEvent ("count-changed", this.count)) override this.render() = let stepButton step = html $"""""" html $""" {this.label} {this.count} times {steps |> List.map stepButton}""" defineElement "click-counter-list" ``` ```html ``` `steps` is a plain F# list. `stepButton` makes a button template for one step, `List.map` turns the list of steps into a list of buttons, and Lit renders them in order. `Increment` becomes `Add`, which takes the step. The new `:host` rule styles the `` element itself, laying out the label and buttons in a row. For a list whose items are added, removed or reordered, Lit's [`repeat` directive](https://lit.dev/docs/templates/lists/#the-repeat-directive) keeps each item's DOM with it. ## Build for production ```sh npm run build ``` This compiles `App.fs` and bundles the app into `dist/`: static files you can host anywhere. `npm run preview` serves them locally, to check the build before you publish it. ## Where next - The [Templates guide](https://roboz0r.github.io/Firelight/guides/templates/): the F# side of the `html` you've been writing. - [Firelight](https://roboz0r.github.io/Firelight/packages/firelight/), the core package, and the [other packages](https://roboz0r.github.io/Firelight/#packages): Elmish, context, signals, routing and more, each with a live example. - The [demos](https://roboz0r.github.io/Firelight/#demos): a todo app, a Kanban board and [client-side routing](https://roboz0r.github.io/Firelight/client-side-routing/). - The [GettingStarted sample](https://github.com/roboz0r/Firelight/tree/main/sample/GettingStarted) on GitHub covers each core concept one module at a time: rendering, properties, events, styles, controllers, context and Elmish. - [Lit's documentation](https://lit.dev/docs/). What it says about components, templates and styles applies to Firelight too. # Firelight compared Source: https://roboz0r.github.io/Firelight/why/ In Firelight you write components as F# classes with HTML templates, and they run as Lit web components. This page sets Firelight beside the other ways to build a browser UI in F# (Fable.Lit, Feliz and Sutil) and beside Lit in TypeScript. Each section ends with when the other choice is the better one. Firelight, Fable.Lit, Feliz and Sutil are all compiled to JavaScript by [Fable](https://fable.io/). What differs is what you write, what ships to the browser, and how the result fits next to other web code. ## At a glance | Library | You write | A component is | A counter ships | |---|---|---|---| | Firelight | HTML in F# strings | A class; a custom element | 6.0 kB | | [Fable.Lit](https://roboz0r.github.io/Firelight/why/#fable-lit) | HTML in F# strings | A function with hooks; optionally a custom element | 10.8 kB | | [Feliz](https://roboz0r.github.io/Firelight/why/#feliz) | F# lists of elements | A function with hooks; a React component | 70.3 kB | | [Sutil](https://roboz0r.github.io/Firelight/why/#sutil) | F# lists of elements | A function with stores | 22.5 kB | | [Lit](https://roboz0r.github.io/Firelight/why/#lit-in-typescript) | HTML in TypeScript strings | A class; a custom element | 5.9 kB | The sizes are for the same app built with each: one button that counts its clicks, as shown in each section below. Each was built with Vite and measured as the gzipped size of all its JavaScript, on 3 October 2026. They show the least each one ships, not what a real app costs. For comparison, the [Todo demo](https://roboz0r.github.io/Firelight/demos/todo/), with Elmish and context, ships 24.2 kB, Lit included. This is the Firelight counter: ```fsharp open Fable.Core open Firelight open type Firelight.Lit [] type ClickCounter() = inherit LitElement() static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration() ] member val count = 0 with get, set override this.render() = html $"""""" defineElement "click-counter" ``` Firelight is also the newest of the F# options, first released in July 2025, and the least downloaded. On NuGet, on 3 October 2026, Feliz had about 1.27 million downloads in total, Fable.Lit 54,000, Sutil 34,000 and Firelight 1,200. ## Fable.Lit [Fable.Lit](https://github.com/fable-compiler/Fable.Lit) is the earlier set of F# bindings for Lit, and the closest alternative. Its templates are the same as Firelight's: Lit's `html`, with the HTML in an F# interpolated string. ```fsharp fragment open Lit [] let ClickCounter () = let _ = LitElement.init () let count, setCount = Hook.useState 0 html $"""""" ``` The components differ. A Fable.Lit component is a function: `[]` registers it as a custom element, and it keeps state in hooks modelled on React's, such as `Hook.useState` and `Hook.useEffect`. `[]` gives a template function state without making it an element, and Fable.Lit.Elmish adds `Hook.useElmish`. A Firelight component is a class that inherits `LitElement`, the shape Lit's own documentation uses, so Lit's [lifecycle methods](https://lit.dev/docs/components/lifecycle/), [reactive controllers](https://lit.dev/docs/composition/controllers/) and examples carry over directly. Elmish runs in a controller, [`ElmishController`](https://roboz0r.github.io/Firelight/packages/elmish/). Event handlers are close. Fable.Lit's `Ev` wraps a handler so F# can type its event, and `EvVal` passes it an input's value. Firelight's [`Ev` module](https://roboz0r.github.io/Firelight/guides/templates/#typed-event-handlers) has a function per kind of event, such as `Ev.keyboard`, and `Ev.value` for an input's value. In both, nothing checks the handler against the event's name. Fable.Lit hasn't changed since 2022. Its last release, 1.4.2, came out on 14 July 2022, and the last commit to its main branch is from 5 September 2022 (both checked on 3 October 2026). Its documentation asks for Fable 3.6 and Lit 2, and Fable.Lit.Elmish was built against Elmish 3. The repository isn't archived, and the counter above still compiles with Fable 5.18 and runs on Lit 3.3.1. Firelight targets Fable 5, Lit 3 and Elmish 5, and binds Lit's companion packages: [context](https://roboz0r.github.io/Firelight/packages/context/), [signals](https://roboz0r.github.io/Firelight/packages/signals/), [task](https://roboz0r.github.io/Firelight/packages/task/), [motion](https://roboz0r.github.io/Firelight/packages/motion/), [observers](https://roboz0r.github.io/Firelight/packages/observers/) and [virtualizer](https://roboz0r.github.io/Firelight/packages/virtualizer/). Fable.Lit has things Firelight doesn't. Fable.Lit.React renders React components inside Lit templates and Lit templates inside React, though it calls `ReactDOM.render`, which React 19 removed, so it needs React 18 or earlier. Fable.Lit.Test has helpers for testing components. With `Hook.useHmr`, a hook component keeps its state when Vite reloads its module; Firelight's nearest equivalent saves an Elmish model to local storage across reloads. Choose Fable.Lit for an app that already uses it and works, or if you'd rather write components as functions with hooks than as classes. Moving an app to Firelight keeps most of the template HTML, since both use Lit's syntax. The components need rewriting as classes, and Fable.Lit's F# helpers, such as `Lit.classes` and `Lit.mapUnique`, become Lit's directives (`classMap`, `repeat`). ## Feliz (React) [Feliz](https://github.com/fable-hub/Feliz) is F# bindings for React. You build elements from F# lists, and a component is a function marked `[]` that keeps its state in React's hooks. ```fsharp fragment open Feliz open Browser.Dom [] let ClickCounter () = let count, setCount = React.useState 0 Html.button [ prop.onClick (fun _ -> setCount (count + 1)) prop.text $"Clicked {count} times" ] let root = ReactDOM.createRoot (document.getElementById "root") root.render (ClickCounter()) ``` The lists are typed. Feliz checks element and attribute names, and many CSS values, when you compile, and `prop.onKeyDown` hands its handler a `KeyboardEvent`. A Firelight template is HTML in a string, so the compiler checks the F# in its holes, not the HTML around them. `` compiles, and the box shows no placeholder; in Feliz, `prop.placeholdr` is a compile error. Firelight's `Ev` functions type a handler's event, but nothing checks that type against the event's name. Feliz.UseElmish runs an Elmish loop inside a component. What runs in the browser is React and React DOM. The counter ships 70.3 kB, with React 19.3. A React component renders into a React root, which can be one part of a page built some other way. A web component needs no root: it works in any HTML page and inside any framework. Since React 19, React sets a prop on a custom element as a property when the element has a property of that name, and as an attribute otherwise ([React 19 release notes](https://react.dev/blog/2024/12/05/react-19), 5 December 2024), so Firelight components can also be used inside a Feliz app. Feliz is actively maintained: 3.3.3 came out on 18 May 2026, and its repository had commits in September 2026. It also has by far the most NuGet downloads of the F# options. Choose Feliz when you want React's ecosystem of component libraries, many of which have Feliz bindings, when your team already knows React, or when you'd rather have typed element lists than HTML. ## Sutil [Sutil](https://github.com/davedawkins/Sutil) builds elements from F# lists too, and keeps state in stores: observables bound to the parts of the DOM that show them, with no virtual DOM. It is modelled on Svelte, supports Elmish, and has no JavaScript dependencies; its runtime is written in F#. ```fsharp fragment open Sutil open Sutil.CoreElements let clickCounter () = let count = Store.make 0 Html.button [ disposeOnUnmount [ count ] Ev.onClick (fun _ -> count |> Store.modify (fun n -> n + 1)) Bind.el (count, fun n -> text $"Clicked {n} times") ] Program.mount ("sutil-app", clickCounter ()) |> ignore ``` The counter ships 22.5 kB, which is Sutil's runtime compiled from F#. This app is mounted into a page element, and Sutil can also register a component as a custom element with `WebComponent.Register`. Sutil's last release, 2.0.16, came out on 22 September 2024. Its repository has had commits as recently as 2 October 2026. Choose Sutil when you want a UI framework written entirely in F#, with no JavaScript framework underneath, and state that updates the DOM through stores. ## Lit in TypeScript Firelight components are Lit components, so this is the closest comparison: the same runtime, and the same components once compiled. The TypeScript counter ships 5.9 kB and the Firelight one 6.0 kB. Firelight's bindings compile away, and the components import Lit directly. ```ts import { LitElement, html } from "lit"; class ClickCounter extends LitElement { static properties = { count: { type: Number } }; declare count: number; constructor() { super(); this.count = 0; } render() { return html``; } } customElements.define("click-counter", ClickCounter); ``` What F# adds is the model behind the UI: records and discriminated unions for state, pattern matching in templates, Elmish, and one language shared with a .NET back end. Immutable values also suit Lit's change detection, which compares by identity (the [Templates guide](https://roboz0r.github.io/Firelight/guides/templates/#common-mistakes) explains). Choose Lit in TypeScript when your team writes TypeScript, or when you're publishing components for JavaScript developers, who can then read the source they run. Lit's own documentation, examples and editor tools are written for TypeScript and JavaScript; with Firelight you read them and translate. ## What to weigh before choosing Firelight - Firelight hasn't reached 1.0, so its API may still change. - It is new, so most of what's written about it is on this site and in its repository. - Templates are strings. The compiler checks the F# in the holes, not the HTML around them, and a hole accepts any value. - You need both the .NET SDK and Node, as with any Fable app. # Using Firelight with coding agents Source: https://roboz0r.github.io/Firelight/agents/ Give a coding agent this site as Markdown and the Firelight skill, and have it check its work in a build and a browser. Firelight templates are HTML and its components are Lit's, so most of what an agent needs is what it already knows; these cover the F# part. HTML, Lit and F# are all widely used and documented, and a Firelight component is those three put together. A template is HTML with [Lit's bindings](https://lit.dev/docs/templates/overview/), a component has the shape of Lit's own classes, and [Fable](https://fable.io/) compiles it to an ordinary Lit component in JavaScript. In this project's experience, agents rarely struggle with Firelight code, and this is why. What's new to an agent is the thin layer that is Firelight's: the F# names for Lit's API, and the F# mistakes a template can hide. The files below cover that layer. ## This site as Markdown Every page of this site, apart from search and "page not found", is also published as Markdown, for agents and anything else that reads text: | File | What it holds | |---|---| | [`llms.txt`](https://roboz0r.github.io/Firelight/llms.txt) | An index: each page's Markdown address, with a line about it | | [`llms-full.txt`](https://roboz0r.github.io/Firelight/llms-full.txt) | Every page in one file, in the order of the index | | `/index.md` | One page, such as [the Templates guide](https://roboz0r.github.io/Firelight/guides/templates/index.md) | In the Markdown, each example's F# source file is inline, followed by the demo's HTML, and every link is absolute. Each page's `` also links its Markdown version (``), so a tool that fetches a page can find it. Give an agent `llms.txt` and let it fetch the pages it needs, or `llms-full.txt` when it should read everything. A line in your project's `CLAUDE.md` or `AGENTS.md` does it: ```md Firelight (F# bindings for Lit) docs, as Markdown: https://roboz0r.github.io/Firelight/llms.txt ``` ## The Firelight skill for Claude Code The Firelight repository has a [skill](https://code.claude.com/docs/en/skills) for Claude Code, [`.claude/skills/firelight/SKILL.md`](https://github.com/roboz0r/Firelight/blob/main/.claude/skills/firelight/SKILL.md). It is one Markdown file with F# examples, and covers: - the packages, and when a piece of UI should be a component or a template function; - defining components: reactive properties, styles, `render` and lifecycle; - events, with typed handlers (`Ev`) and custom events; - Elmish in a component, context, reactive controllers and routing; - directives, and the attribute or property choice in bindings; - whole-app patterns, using web component libraries and wrapping imperative JavaScript libraries; - rules (immutability, pure Elmish, Lit SSR in Node only, an app shell) and anti-patterns. To use it in your project, copy it into your project's `.claude/skills/` folder: ```sh mkdir -p .claude/skills/firelight curl -o .claude/skills/firelight/SKILL.md https://raw.githubusercontent.com/roboz0r/Firelight/main/.claude/skills/firelight/SKILL.md ``` Claude Code loads the skill when a task matches its description, such as building UI with Firelight, and `/firelight` loads it directly. Commit it, so everyone working on the project gets it. The skill on `main` follows the latest Firelight. For an older version, copy the skill from that version's tag, such as `v0.2.0`. ## What helps agents on a Fable and Lit codebase These come from building this site and its examples with agents. **Have it build after every change.** `dotnet fable` type-checks the F# and reports errors with file and line, which an agent can fix on its own. A build doesn't catch everything, because a template hole accepts any value: the [Templates guide](https://roboz0r.github.io/Firelight/guides/templates/#common-mistakes) lists the mistakes that compile silently. This site goes further and compiles the F# examples in its pages (`tests/Docs.Snippets`), so an example an agent writes that doesn't compile, or stops compiling when the API changes, fails the build. **Write down the rules the code doesn't show.** This site prerenders its demos in Node at build time with [Lit SSR](https://lit.dev/docs/ssr/authoring/), so a component may only touch `window` or `document` in `connectedCallback`, `firstUpdated`, `updated` or event handlers. Nothing in a component says that, so the rule is in the site's `CLAUDE.md`, where agents read it before they start. Do the same for any rule of your own. **Have it check in a browser.** Some Lit mistakes compile and fail only at runtime, and some fail without a console message. This site's end-to-end tests (Playwright) open every page and fail on console errors, failed requests, custom element tags that were never defined, and prerendered components that render a second copy of themselves when they load. Ask an agent to write such a check for what it builds, and to run it. **Point it at the Markdown, not the HTML.** A page's HTML wraps the text in navigation, highlighted code and prerendered shadow DOM. Its Markdown has the text and the source files, and nothing else. # Firelight Source: https://roboz0r.github.io/Firelight/packages/firelight/ The core package: F# bindings for Lit. Define web components with reactive properties, scoped styles and `html` templates. - [Lit docs: Components](https://lit.dev/docs/components/overview/) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight) ## Why use it Web components are the browser's own component model. A custom element works in any page, alongside any framework or none. Lit takes away the boilerplate: you declare properties and return a template, and Lit updates only the parts of the page that changed. Firelight lets you write those components in F#. Templates are F# interpolated strings, so the values and event handlers in them are ordinary typed F# code. The bindings themselves compile away: your component imports Lit directly, and there is no Firelight runtime to ship. ## Example A star rating. `value` is a reactive property that also reads and writes the `value` attribute, the styles are scoped to the component, and choosing a star raises a `rating-changed` event. The message below comes from plain JavaScript on this page listening for that event. ```fsharp module Snippets.Rating open Fable.Core open Fable.Core.JsInterop open Browser.Types open Firelight open type Firelight.Lit // Each star is a button named "n out of 5"; the one matching the rating is pressed. let private star (n: int) (value: int) (select: unit -> unit) = html $"""""" /// /// Clicking a star sets `value` and raises a "rating-changed" event for the page to handle. [] type Rating() = inherit LitElement() static member properties = PropertyDeclarations.create [ "value", PropertyDeclaration(reflect = true) ] static member styles = css $$""" button { all: unset; cursor: pointer; font-size: 2rem; color: var(--muted); border-radius: 0.25rem; } button.on { color: var(--accent); } button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } """ member val value = 0 with get, set member this.Select(stars: int) = this.value <- stars this.dispatch (Event.customEvent ("rating-changed", stars)) override this.render() = html $"""{[ for n in 1..5 -> star n this.value (fun () -> this.Select n) ]}""" defineElement "my-rating" ``` ```html

Pick a rating.

``` ## Install ```sh dotnet add package Firelight npm install lit ``` New to Firelight? The [Get started](https://roboz0r.github.io/Firelight/start/) tutorial creates an app from a template, then gives its component an attribute, an event, styles and a list. # Firelight.Context Source: https://roboz0r.github.io/Firelight/packages/context/ Implicitly share values with any descendant component in the DOM tree, without prop drilling. - [Lit docs: Context](https://lit.dev/docs/data/context/) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Context/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Context) ## Why use it Some values are needed deep in the component tree: the current theme, the active user, or an Elmish `dispatch` function. Passing them down as properties forces every component in between to accept and forward values it doesn't use. Context solves this with an event-based protocol that runs through the DOM: - **No intermediate plumbing:** a provider component declares a value, and any descendant component can request it. The components in between don't know the value passes through them. - **Subtree scoping:** unlike global stores or signals, context follows the DOM hierarchy. Nest providers to scope a value locally, for example a dark theme for a single card while the rest of the page stays light. - **Typed keys:** a Firelight context key is branded with the value's F# type, so a consumer that requests a `Theme` receives a `Theme`. When the provider's value changes, subscribed consumers re-render. ## Example The provider owns the theme and shows its children through a slot. The badges are nested inside plain `
`s, and nothing passes the theme to them. ```fsharp module Snippets.ThemeContext open Fable.Core open Fable.Core.JsInterop open Firelight open Firelight.Context open type Firelight.Lit type Theme = | Ember | Ocean // A context is a symbol branded with the type of value it carries. type ThemeContext = inherit Context inherit symbol let themeContext: ThemeContext = LitContext.createContext (JS.Symbol "theme") /// Provides the current theme to everything inside it, however deeply nested. [] type ThemeProvider() = inherit LitElement() let mutable theme = Ember let provider = ContextProvider(jsThis, ContextProvider.Options(themeContext, theme)) static member styles = css $$""" :host { display: grid; gap: 1rem; justify-items: start; } button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; } """ member this.Toggle() = theme <- if theme = Ember then Ocean else Ember provider.setValue theme this.requestUpdate () override this.render() = html $""" """ /// Reads the theme from the nearest provider. Nothing in between passes it along. [] type ThemedBadge() = inherit LitElement() let theme = ContextConsumer(jsThis, ContextConsumer.Options(themeContext, subscribe = true)) static member styles = css $$""" span { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 1rem; color: white; } .Ember { background: #c2410c; } .Ocean { background: #1971c2; } """ override _.render() = let name = string (theme.value |> Option.defaultValue Ember) html $"""{name}""" defineElement "my-theme-provider" defineElement "my-themed-badge" ``` ```html
``` The [Todo demo](https://roboz0r.github.io/Firelight/demos/todo/) uses the same pattern to share its Elmish state and `dispatch` with each item. ## Providers that load later A consumer asks for its value once, when it connects. If the provider's element isn't defined yet, because its module loads later or lazily, nobody answers and the consumer waits for good. A `ContextRoot` attached to the page keeps those requests and sends them again when a provider connects. It keeps only requests from consumers with `subscribe = true`. Here the consumer connects inside a `` that isn't defined yet. Click the button to define it: ```fsharp module Snippets.LateProvider open Fable.Core open Fable.Core.JsInterop open Browser open Firelight open Firelight.Context open type Firelight.Lit type GreetingContext = inherit Context inherit symbol let greetingContext: GreetingContext = LitContext.createContext (JS.Symbol "greeting") // Keeps the requests nobody answered, and sends them again when a provider appears. ContextRoot().attach document.body /// Asks for the greeting as soon as it connects, before any provider exists. [] type LateConsumer() = inherit LitElement() // ContextRoot only keeps requests that subscribe. let greeting = ContextConsumer(jsThis, ContextConsumer.Options(greetingContext, subscribe = true)) override _.render() = match greeting.value with | Some text -> html $"

{text}

" | None -> html $"

Waiting for a provider…

" [] type LateProvider() = inherit LitElement() let provider = ContextProvider(jsThis, ContextProvider.Options(greetingContext, "Hello from the provider")) override _.render() = html $"" /// Defines on click, so the provider upgrades after its consumer asked. [] type ProviderLoader() = inherit LitElement() let mutable loaded = false member this.Load() = defineElement "my-late-provider" loaded <- true this.requestUpdate () override this.render() = html $"""""" defineElement "my-late-consumer" defineElement "my-provider-loader" ``` ```html ``` Attach the root once, before the consumers connect, to an element above both: `document.body`, or your app's root element. ## Install ```sh dotnet add package Firelight.Context npm install @lit/context ``` # Firelight.Elmish Source: https://roboz0r.github.io/Firelight/packages/elmish/ Run the Model-View-Update loop inside a component: one immutable model, one pure `update` function. - [Elmish docs](https://elmish.github.io/elmish/) - [Lit docs: Reactive controllers](https://lit.dev/docs/composition/controllers/) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Elmish/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Elmish) ## Why use it As a component's state grows, mutable fields changed from different event handlers get hard to follow. Elmish keeps all of the state in one immutable model, and every change goes through a single `update` function: given a message and the current model, it returns the next model. That function is plain F#, with no DOM or Lit in it, so it's easy to read and to test. `ElmishController` connects the loop to a Lit component. It's a [reactive controller](https://lit.dev/docs/composition/controllers/): `init` runs when the component is created, and each new model renders the component. Use `ElmishController.withCmds` for side effects such as HTTP requests, or pass a full Elmish `Program` for subscriptions and tracing. ## Example Because every model is an immutable value, undo is just a list of the earlier ones. Nothing in the component has to know how to reverse a change. ```fsharp module Snippets.UndoCounter open Fable.Core open Firelight open Firelight.Elmish open type Firelight.Lit // The whole state is one immutable value, so undo is just keeping the earlier values. type Model = { Count: int; History: int list } type Msg = | Increment | Decrement | Undo let init () = { Count = 0; History = [] } let update msg model = match msg with | Increment -> { Count = model.Count + 1 History = model.Count :: model.History } | Decrement -> { Count = model.Count - 1 History = model.Count :: model.History } | Undo -> match model.History with | previous :: older -> { Count = previous; History = older } | [] -> model [] type UndoCounter() as this = inherit LitElement() let elmish = ElmishController.simple this init update static member styles = css $$""" :host { display: inline-flex; align-items: center; gap: 0.75rem; } button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; } output { min-width: 2ch; text-align: center; font-weight: 600; } """ override _.render() = let model = elmish.model html $""" {model.Count} """ defineElement "my-undo-counter" ``` ```html ``` For a larger example, the [Todo demo](https://roboz0r.github.io/Firelight/demos/todo/) runs one Elmish loop and shares its state with each item through context. ## Install ```sh dotnet add package Firelight.Elmish ``` # Firelight.Router Source: https://roboz0r.github.io/Firelight/packages/router/ Type-safe client-side routing powered by the browser's URL Pattern API, with routes modelled as an F# union. - [MDN: URL Pattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Router/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Router) ## Why use it Firelight.Router parses browser URLs into your own F# discriminated union. This turns page routing into a strongly typed pattern match, and helps the compiler ensure every route is handled. `RouterController` keeps your component synchronized with the browser location. It intercepts clicks on matching links, manages back and forward history, and handles smooth scrolling for `#hash` anchors. Unmatched links fall back to standard browser navigation. `routing.Navigate` does the same from code. Firelight.Router builds directly on the web standard [URL Pattern API](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) (polyfilled for browsers that don't yet support it natively). ## Example Patterns can constrain their parameters: `:id(\d+)` only matches digits, so `/users/abc` falls through to the catch-all `*` route and renders `NotFound` without leaving the page. ```fsharp module Site.Routing.RouteExplorer open Fable.Core open Fable.Core.JsInterop open Browser open Browser.Types.URLPattern open Firelight open Firelight.Router open type Firelight.Lit importPolyfill () // Every page the router knows about, as a type. Matching a URL produces one of these. type Route = | Home | User of id: int | UserPost of userId: int * slug: string | NotFound /// Vite's `base`: "/Firelight/" on GitHub Pages. let basePath: string = emitJsExpr () "import.meta.env.BASE_URL" let root = basePath + "client-side-routing/" let private group (name: string) (result: URLPatternResult) = result.pathname.groups.[name] |> Option.defaultValue "" // (\d+) only matches digits, so "users/abc" falls through to the catch-all. // The catch-all keeps every address under root inside the app; links to anywhere else // (other pages of the site) match no route, so the browser navigates to them normally. let router = [ root + "{index.html}?", (fun _ -> Home) root + "users/:id(\\d+)", (fun r -> User(int (group "id" r))) root + "users/:id(\\d+)/posts/:slug", (fun r -> UserPost(int (group "id" r), group "slug" r)) root + "*", (fun _ -> NotFound) ] |> createRouter NotFound let private examples = [ "" "users/42" "users/42/posts/hello-lit" "users/7/posts/routing-in-fsharp" "users/abc" ] let private exampleLink (path: string) = html $"""
  • /{path}
  • """ [] type RouteExplorer() as this = inherit LightDomElement() let routing = RouterController(this, router) override _.render() = let description = match routing.route with | Home -> "the list of examples" | User id -> $"the profile of user {id}" | UserPost(userId, slug) -> $"post \"{slug}\" by user {userId}" | NotFound -> "a not-found page" // Navigate goes to an address from code, as a click on a link to it would. let nextUser = match routing.route with | User id | UserPost(id, _) -> id + 1 | Home | NotFound -> 1 let goToNextUser _ = routing.Navigate(root + $"users/{nextUser}") html $"""

    Address
    {window.location.pathname}
    Route value
    {sprintf "%A" routing.route}
    Renders
    {description}
    """ defineElement "fl-route-explorer" ```

    Try it on the client-side routing page

    ## Navigate from code `routing.Navigate url` goes to `url` the way a click on a link to it does. When one of the router's routes matches, the address changes, a history entry is added and the host renders the new route, without loading a page. When none matches, the browser loads the address, as it does for such a link. The example's "Go to user" button calls it. | Call | What it does | |---|---| | `routing.Navigate "/users/42"` | Adds a history entry, so Back returns | | `routing.Navigate ("/users/42", replace = true)` | Replaces the current entry, so Back skips it: after a form is saved, say | The address may be relative to the current one. Going to the current address does nothing. ## Routes as Elmish messages With [Firelight.Elmish](https://roboz0r.github.io/Firelight/packages/elmish/), keep the route in the model. Give the controller a function as its third argument: it gets the route after every change (a link, Back or Forward, `Navigate`) and when the host connects, so it can dispatch a message. ```fsharp open Fable.Core open Browser.Types.URLPattern open Firelight open Firelight.Elmish open Firelight.Router open type Firelight.Lit // Before the router is created: browsers without URLPattern get the polyfill. importPolyfill () type Route = | Home | User of id: int | NotFound let router = [ "/", (fun _ -> Home) "/users/:id(\\d+)", (fun (r: URLPatternResult) -> User(int (r.pathname.groups.["id"] |> Option.defaultValue "0"))) ] |> createRouter NotFound type Model = { Route: Route; Visits: int } type Msg = RouteChanged of Route let update msg model = match msg with | RouteChanged route -> { Route = route; Visits = model.Visits + 1 } [] type App() as this = inherit LitElement() let loop = ElmishController.simple this (fun () -> { Route = router.OfLocation(); Visits = 0 }) update let routing = RouterController(this, router, fun route -> loop.dispatch (RouteChanged route)) override _.render() = match loop.model.Route with | Home -> html $"""""" | User id -> html $"

    User {id}, page {loop.model.Visits}

    " | NotFound -> html $"

    Not found

    " ``` `update` stays pure: it only records the route it's given. To change the route as the result of a message, call `routing.Navigate` from the component, where the controller is, rather than from `update`. ## Install ```sh dotnet add package Firelight.Router npm install urlpattern-polyfill ``` Call `importPolyfill ()` once, at the top level of your app's module, before you create a router, as the example does. A module that creates a router as it loads needs the call itself, above that code, because the modules it imports run first. A browser with native `URLPattern` skips the download. The call compiles to a top-level `await`, which JavaScript allows only at the top level: inside a function, the compiled module fails to load. There, `loadPolyfill ()` returns a promise to wait on instead. # Firelight.Signals Source: https://roboz0r.github.io/Firelight/packages/signals/ Reactive state that lives outside your components, shared by any component that reads it. - [Lit docs: Signals](https://lit.dev/docs/data/signals/) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Signals/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Signals) ## Why use it Lit's context passes state through the DOM hierarchy with providers and consumers. Signals keep reactive state outside the DOM. A signal is a standalone value that tracks who reads it: - **Subscriptions without wiring:** any component registered with `LitSignals.defineElement` re-renders when a signal it read while rendering changes. This needs no providers, context keys or manual subscriptions. - **Derived state:** `computed` creates values from other signals. They stay up to date, compute lazily and cache their results. - **Surgical updates:** with `LitSignals.html`, a signal interpolated into a template updates only that binding, instead of re-rendering the whole component (as long as the render doesn't also read the signal elsewhere). Lit's signals are based on the TC39 Signals proposal and are still experimental. ## Example The button and the total are separate elements on this page, with no shared parent, property or event between them. ```fsharp module Snippets.SharedSignal open Fable.Core open Firelight open Firelight.Signals open type LitSignals // State outside any component. Every component that reads it stays in sync. let clicks = signal 0 let doubled = computed (fun () -> clicks.get () * 2) [] type ClickButton() = inherit LitElement() static member styles = Lit.css $$"""button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; }""" override _.render() = html $"""""" // A separate component, with no parent, property or event connecting it to the button. // Interpolated signals update just their part of the template. [] type ClickTotal() = inherit LitElement() override _.render() = html $"""

    {clicks} clicks, doubled is {doubled}

    """ defineElement "my-click-button" defineElement "my-click-total" ``` ```html ``` ## Install ```sh dotnet add package Firelight.Signals npm install @lit-labs/signals ``` # Firelight.Motion Source: https://roboz0r.github.io/Firelight/packages/motion/ Animate elements as they move, appear and disappear, declared right in the template. - [Lit Labs: @lit-labs/motion](https://github.com/lit/lit/tree/main/packages/labs/motion) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Motion/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Motion) ## Why use it When a render moves an element, it jumps to its new place. Animating that by hand means measuring where the element was before the update and where it is after, then animating between the two. `Motion.animate()` does that for you: put it on an element in a template, and when a render moves the element, it slides from its old position to its new one. It can also animate elements in and out of the page with keyframe presets such as `Motion.fadeIn`. `AnimateController` controls the animations across a component, for example to pause them, and `SpringController` adds spring physics. ## Example The list is re-rendered in a random order. Each item is the same element as before, animated to its new place. ```fsharp module Snippets.ShuffleList open Fable.Core open Firelight open Firelight.Motion open type Firelight.Lit let private random = System.Random() [] type ShuffleList() = inherit LitElement() let mutable fruits = [ "Apple"; "Banana"; "Cherry"; "Damson"; "Elderberry" ] static member styles = css $$""" :host { display: grid; gap: 1rem; justify-items: start; } button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; } ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.4rem; min-width: 12rem; } li { padding: 0.4rem 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); } """ member this.Shuffle() = fruits <- fruits |> List.sortBy (fun _ -> random.Next()) this.requestUpdate () // repeat keys each
  • by fruit, so the same element moves to its new position, // and Motion.animate() animates it there from where it was. override this.render() = html $"""
      {repeat (fruits, (fun fruit _ -> fruit), (fun fruit _ -> html $"
    • {fruit}
    • "))}
    """ defineElement "my-shuffle-list" ``` ```html ``` ## Install ```sh dotnet add package Firelight.Motion npm install @lit-labs/motion ``` # Firelight.Observers Source: https://roboz0r.github.io/Firelight/packages/observers/ Reactive controllers for the browser's resize, intersection, mutation and performance observers. - [Lit Labs: @lit-labs/observers](https://github.com/lit/lit/tree/main/packages/labs/observers) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Observers/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.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. ```fsharp 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. [] type ResizePanel() as this = inherit LitElement() let width = ResizeController( 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 $"""

    {int px}px wide, so the {layout} layout

    One
    Two
    Three
    """ defineElement "my-resize-panel" ``` ```html ``` ## 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: `
    `. The element is observed while it's rendered. Scroll the box until the marker shows: ```fsharp 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. [] 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 $"""

    The marker is {if inView then "in view" else "out of view"}.

    Scroll down.
    Marker
    """ defineElement "my-scroll-marker" ``` ```html ``` `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 ```sh dotnet add package Firelight.Observers npm install @lit-labs/observers ``` # Firelight.Task Source: https://roboz0r.github.io/Firelight/packages/task/ Run async work from a component and render its pending, complete and error states. - [Lit docs: Async tasks](https://lit.dev/docs/data/task/) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Task/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Task) ## Why use it Loading data in a component takes more than making the request. You need a loading state, the result or the error, a new request when an input changes, and a way to ignore an older request that finishes after a newer one. `LitTask` handles all of that. Give it a function that returns a promise and an `args` function: the task runs whenever the arguments change, renders the component again as its status changes, and `render` picks the template for the current status. The task function also receives an `AbortSignal` you can pass to `fetch`, so requests that are no longer needed get cancelled ([Cancelling requests](https://roboz0r.github.io/Firelight/packages/task/#cancelling-requests)). The F# type is called `LitTask` so it doesn't clash with .NET's `Task`. ## Example Each button changes the task's argument, which starts a new request. Product 3 fails, to show the error state. Clear sets the argument to an empty id, for which the task function returns `initialState`: the task goes back to its initial state, which `render` shows with its `initial` template, and no request starts.

    args must return a plain JavaScript array. Fable compiles numeric arrays such as int[] to typed arrays (Int32Array), which @lit/task rejects, so this example uses string ids. Arrays of strings, records or obj are fine.

    ```fsharp module Snippets.ProductTask open Fable.Core open Firelight open Firelight.Task open type Firelight.Lit // Stands in for a real request, such as a fetch to your API. let fetchProduct (id: string) : JS.Promise = promise { do! Promise.sleep 800 if id = "3" then failwith "Product 3 is out of stock" return $"Product {id} is in stock" } let private status = StatusRenderer( initial = (fun () -> html $"

    Pick a product.

    "), pending = (fun () -> html $"

    Loading…

    "), complete = (fun (text: string) -> html $"

    {text}

    "), error = (fun error -> html $"

    {error}

    ") ) [] type ProductView() as this = inherit LitElement() // Product ids are strings: @lit/task needs a plain JS array of args, and Fable compiles int[] to an Int32Array. let mutable productId = "1" // Runs again whenever productId changes, and re-renders as the request goes // from pending to complete or error. With no product, initialState puts the task // back in its initial state, without a request. (args always holds one id, but a // match must cover every length, so the other lengths go there too.) let product = LitTask( this, TaskConfig( TaskFunction(fun (args: string[]) _ -> match args with // U2.Case2, not !^: nothing here says if the task's result is the string or the promise. | [| id |] when id <> "" -> U2.Case2(fetchProduct id) | _ -> initialState), args = (fun () -> [| productId |]) ) ) static member styles = css $$""" :host { display: grid; gap: 0.5rem; justify-items: start; } button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; } .error { color: #c92a2a; } """ member this.Show(id: string) = productId <- id this.requestUpdate () member this.ProductButton(id: string) = html $"""""" override this.render() = html $"""
    {[ "1"; "2"; "3" ] |> List.map this.ProductButton}
    {product.render status |> Option.defaultValue (html $"")}""" defineElement "my-product-view" ``` ```html ``` `fetchProduct` builds its promise with `promise { }`, from Fable.Promise, which comes with Firelight.Task through Fable.Fetch; reference Fable.Promise in your project when your code uses it. The task function takes the id out of `args` with the pattern `[| id |]`, and wraps the promise in `U2.Case2`: `!^` would need the task's result type, which nothing in this lambda gives it ([Tasks](https://roboz0r.github.io/Firelight/from-lit/tasks/#loading-when-arguments-change) has the details). ## Cancelling requests The task function's second argument has one member, `signal`. The task aborts it when the run is no longer wanted: its arguments changed and a newer run started, or you called `abort`. Pass it to the request, and the browser cancels the request. Its type is Fable.Fetch's `AbortSignal`. Firelight.Task depends on [Fable.Fetch](https://github.com/fable-compiler/fable-fetch), the binding for the browser's `fetch`, so with `open Fetch` the signal goes straight to `fetch`'s `Signal` option: ```fsharp open Fable.Core.JsInterop open Fetch open Firelight.Task let loadText (url: string) (signal: AbortSignal) = promise { let! response = fetch url [ Signal signal ] return! response.text () } let loadPage (args: string[]) (options: TaskFunctionOptions) : TaskResult = match args with | [| url |] -> !^(loadText url options.signal) | _ -> initialState ``` Pass it as `TaskFunction loadPage`. The value is the browser's own `AbortSignal` object, so any other API that takes a signal accepts it too. In a binding of your own, such as a hand-written `fetch`, type the parameter as Fable.Fetch's `AbortSignal`: ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight.Task [] let fetch (url: string, init: {| signal: Fetch.Types.AbortSignal |}) : JS.Promise = jsNative let loadPage (args: string[]) (options: TaskFunctionOptions) : TaskResult = match args with | [| url |] -> !^(fetch (url, {| signal = options.signal |})) | _ -> initialState ``` Where another library's binding has an `AbortSignal` type of its own, convert at the call with `unbox options.signal`, which compiles to nothing. Work that isn't a request can call `options.signal.throwIfAborted ()` after each wait, as [Search as you type](https://roboz0r.github.io/Firelight/cookbook/debounced-search/) does, or read `options.signal.aborted`. ## Install ```sh dotnet add package Firelight.Task npm install @lit/task ``` The first command also installs Fable.Fetch. # Firelight.Virtualizer Source: https://roboz0r.github.io/Firelight/packages/virtualizer/ Render long lists quickly by keeping only the visible items in the DOM. - [Lit Labs: @lit-labs/virtualizer](https://github.com/lit/lit/tree/main/packages/labs/virtualizer) - [NuGet 0.3.0](https://www.nuget.org/packages/Firelight.Virtualizer/0.3.0) - [Source](https://github.com/roboz0r/Firelight/tree/main/src/Firelight.Virtualizer) ## Why use it A list of ten thousand rows is ten thousand DOM elements, which are slow to create and make the page heavy to scroll. The virtualizer renders only the items in view, plus a few either side, and reuses them as you scroll, so the cost stays about the same however long the list gets. Use the `Virtualizer.virtualize` directive inside your own scrolling element, as below, or register the `` element with `Virtualizer.defineElement ()`. A handle from `Virtualizer.get` can scroll to any item, even one that isn't rendered yet. ## Example Ten thousand rows. Open your browser's developer tools on the list: only the rows near the visible ones exist in the DOM. ```fsharp module Snippets.BigList open Fable.Core open Firelight open Firelight.Virtualizer open type Firelight.Lit let rows = [| for i in 1..10_000 -> $"Row {i}" |] let private row = RenderItem(fun text _ -> html $"
  • {text}
  • ") /// Ten thousand rows, but only the ones in view exist in the DOM. [] type BigList() = inherit LitElement() static member styles = css $$""" :host { display: block; width: 100%; } ul { height: 16rem; overflow: auto; margin: 0; padding: 0; list-style: none; border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); } li { width: 100%; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); } """ override _.render() = html $"""
      {Virtualizer.virtualize (VirtualizeConfig(rows, row, scroller = true))}
    """ defineElement "my-big-list" ``` ```html ``` ## Install ```sh dotnet add package Firelight.Virtualizer npm install @lit-labs/virtualizer ``` # Guides Source: https://roboz0r.github.io/Firelight/guides/ One topic at a time, with live examples. Each guide covers what Firelight adds to Lit and links to the Lit docs for the rest. - [Templates](https://roboz0r.github.io/Firelight/guides/templates/): plain HTML in F# interpolated strings, with bindings, conditionals, lists, directives and static values. - [Properties and attributes](https://roboz0r.github.io/Firelight/guides/properties/): reactive properties, attributes, change detection with F# values, reflection and internal state. - [Events](https://roboz0r.github.io/Firelight/guides/events/): listen with typed handlers, raise custom events with typed details, and how far events travel through shadow DOM. - [Styling](https://roboz0r.github.io/Firelight/guides/styling/): scoped styles, theming with custom properties, parts, shared styles and the safe way to pass values from F#. - [Lifecycle](https://roboz0r.github.io/Firelight/guides/lifecycle/): connecting, updating and cleaning up, and which lifecycle method to override for what. - [Controllers](https://roboz0r.github.io/Firelight/guides/controllers/): state and lifecycle that any component can reuse. - [Prerendering components at build time](https://roboz0r.github.io/Firelight/guides/prerendering/): Lit SSR at build time, declarative shadow DOM, hydration and prerender-safe components. - [Component communication](https://roboz0r.github.io/Firelight/guides/communication/): properties down, events up, and context, signals or Elmish across. - [Using web component libraries](https://roboz0r.github.io/Firelight/guides/component-libraries/): Web Awesome, Shoelace, Fluent UI or Carbon tags in F# templates. - [Using JavaScript libraries](https://roboz0r.github.io/Firelight/guides/js-libraries/): npm libraries in components, with imports, bindings, loading on demand and lifecycle. - [App architecture and loading](https://roboz0r.github.io/Firelight/guides/architecture/): structure a larger app, place its state, and load it fast without SSR. # Templates Source: https://roboz0r.github.io/Firelight/guides/templates/ Firelight templates are plain HTML written inside F# interpolated strings. Passing them to Lit's `html` tag function enables efficient rendering and surgical DOM updates. The bindings are Lit's too, so what you know about HTML and Lit carries over. This guide covers the parts that are F#. - [Lit docs: Templates](https://lit.dev/docs/templates/overview/) - [Lit docs: Expressions](https://lit.dev/docs/templates/expressions/) - [Lit docs: Built-in directives](https://lit.dev/docs/templates/directives/) A template is the value `html` returns: a description of some DOM that Lit renders, then updates when the values in it change. A component returns one from `render`, and so can any function. Most of a user interface can be ordinary F# functions that take data and return templates. Only the parts that need their own state or lifecycle have to be components. ## Templates are interpolated strings ```fsharp open Firelight open type Firelight.Lit let greeting (name: string) = html $"""

    Hello, {name}.

    """ ``` `open type Firelight.Lit` brings `html`, `css`, `nothing` and the directives into scope. Lit's `html` is a JavaScript tagged template, which keeps a template's fixed text apart from the values in its holes, such as `{name}` above. An F# interpolated string passed to `html` is a `FormattableString`, which makes the same split, and [Fable](https://fable.io/) compiles it to a tagged template. Lit sees exactly what it would see from JavaScript: it parses each template once, and on later renders it updates only the values that changed. The split also makes templates safe by default. Values are never pasted into the HTML. A string in a hole becomes text or an attribute value, so a ` ``` For the same reason, a script on a page with prerendered components must not import Lit, or a module that uses Lit, statically. ## Opting a component out Some components can't render the same at build time as in the browser. Leave them out of the prerendered HTML: an element that isn't registered when Lit SSR runs is written out as it is, and renders in the browser like any other. Three of this site's package pages do this, with `ssr=false` on their demos: - **Router**: `RouterController` reads `window.location` when it's created, and a page rendered at build time has no address, while the routing demo serves many from one page. - **Virtualizer**: which rows it renders depends on the size of the scrolling element, which only the browser can measure. - **Task**: `LitTask` starts its task in `hostUpdate`, which Lit SSR doesn't call, so the browser's first render shows the pending state where the build rendered the initial one. On this site, leaving a demo's module out of one page isn't enough, because the build renders every page in one Node process: once any page has registered an element, Lit SSR prerenders it everywhere. So the renderer replaces an `ssr=false` demo's HTML with a placeholder comment before rendering, and puts it back afterwards. ## How this site does it The site follows the same steps. A Vite plugin in `vite.config.js` gives Rollup one virtual `.html` input per Markdown page, and renders each when Rollup loads it: ```js resolveId: (id) => (inputs.has(id) ? id : undefined), load: (id) => (inputs.has(id) ? render(inputs.get(id)) : undefined), ``` `render` calls the renderer, an F# project in `site/Renderer` that [Fable](https://fable.io/) compiles to JavaScript. It imports each demo's module, so the demo's elements are registered, then renders the whole page with Lit SSR. In `Prerender.fs`: ```fsharp fragment // Registering a demo's custom elements is what makes Lit SSR prerender them. for demo in body.Demos do if demo.Prerender then do! host.loadModule demo.Module ``` `LitSsr.fs` binds the two functions it needs from `@lit-labs/ssr`: ```fsharp fragment [] let private render (value: obj) : RenderResult = jsNative [] let private collectResult (result: RenderResult) : JS.Promise = jsNative /// Renders a template, including the shadow roots of any registered custom elements, to a string. let renderToString (template: TemplateResult) : JS.Promise = collectResult (render template) ``` The page around the demos is a server-only template (the `html` from `@lit-labs/ssr`), so only the demos carry hydration markers. `Layout.fs` then writes the module script shown under [The double render](https://roboz0r.github.io/Firelight/guides/prerendering/#the-double-render), with an `import()` for each demo on the page. ## Prerender your own Vite project Start from the project `dotnet new firelight` creates (see [Get started](https://roboz0r.github.io/Firelight/start/)), with its ``. Install Lit SSR for the build, and hydration support for the browser: ```sh npm install @lit-labs/ssr-client npm install --save-dev @lit-labs/ssr ``` In `index.html`, replace the ` ``` Add a plugin to `vite.config.js` that renders the component into the page when you build: ```js import { defineConfig } from "vite"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; // Renders with Lit SSR when building, so the page shows it before any // JavaScript has loaded. index.html then hydrates it in the browser. function prerender() { return { name: "prerender", apply: "build", async transformIndexHtml(page) { const { render, html } = await import("@lit-labs/ssr"); const { collectResult } = await import("@lit-labs/ssr/lib/render-result.js"); // Importing the app registers its elements. Fable has compiled it before `vite build` runs. await import(pathToFileURL(resolve(import.meta.dirname, "build/App.js")).href); const counter = await collectResult(render(html``)); return page.replace("", counter); }, }; } // https://vite.dev/config/ export default defineConfig({ // `npm run dev` starts Vite from `dotnet fable watch`, so keep Fable's compiler output on screen. clearScreen: false, plugins: [prerender()], }); ``` Run `npm run build`, then `npm run preview`. The built `dist/index.html` has the counter's declarative shadow DOM, and with JavaScript off the page still shows "Clicked 0 times". `npm run dev` doesn't prerender, and needs nothing else: hydration support leaves components without a prerendered shadow root to render as usual. The import uses a file URL built from `import.meta.dirname`, rather than `import("./build/App.js")`, so that Vite, which bundles `vite.config.js` before running it, leaves the import to Node. To prerender more elements, render each the same way, or render a larger piece of the page. ## Common mistakes Some mistakes stop the build or show an error in the browser: | You wrote | What you see | Write instead | |---|---|---| | `window`, `document` or `localStorage` at a module's top level, in a constructor, `willUpdate` or `render` | The build fails: "ReferenceError: window is not defined" | Use them in `connectedCallback` or later | | A different template in the browser's first render, from an `if` on browser state | The console: "Hydration value mismatch: Unexpected TemplateResult rendered to part" | Render the same first, and change after `updateComplete` | Others fail silently: | You wrote | What happens | Write instead | |---|---|---| | A static import of Lit or your components before hydration support | Each component renders a second copy of itself | Hydration support, then `import()` | | `{message}`, which is `""` or `None` at first | Text set later never appears: Lit writes it into a comment | `nothing` until there's text: `{if message <> "" then html $"{message}" else nothing}` | | `.checked={false}`, `.disabled={false}` or `.hidden={false}` | Lit SSR writes `checked="false"`, so the box is ticked until the component hydrates, and for good without JavaScript | `?checked={flag}`, and see the rules above | | Browser state, such as `window.innerWidth`, in the first render | The prerendered text stays, though Lit thinks it has changed | Show it after `updateComplete` | | A `LightDomElement` | The prerendered copy stays on screen and the live one is hidden | `LitElement`, or leave it out | | `if changed.ContainsKey "items" then ...` in `willUpdate` | Not run at build time while `items` has its `member val` value, so the prerendered HTML lacks what it computes | `if not this.hasUpdated \|\| changed.ContainsKey "items"` | # Component communication Source: https://roboz0r.github.io/Firelight/guides/communication/ A parent passes data to a child by setting its properties, and a child tells its parent what happened by raising an event. These are the DOM's own channels, so a Firelight component talks to any element the same way. For components far apart, share state through context, signals or a single Elmish loop. - [Lit docs: Events](https://lit.dev/docs/components/events/) - [Lit docs: Context](https://lit.dev/docs/data/context/) Only components need these patterns. A template function gets everything it shows as arguments, `dispatch` included, so most of an app's pieces talk to each other through ordinary F# function calls. The patterns here are for the places where one custom element has to reach another. ## Choosing a pattern | To share | Use | Example | |---|---|---| | Data from a parent to its child | Properties | `` | | What happened, from a child to its parent | Events | `@color-picked={...}` | | A value for a whole subtree | [Context](https://roboz0r.github.io/Firelight/packages/context/) | A theme, the signed-in user, `dispatch` | | State between unrelated components | [Signals](https://roboz0r.github.io/Firelight/packages/signals/) | A basket count in the header and on a page | | The state of an app or a feature | One [Elmish](https://roboz0r.github.io/Firelight/packages/elmish/) loop | A board, its columns and cards | Start with properties and events. They keep each component independent: a child knows nothing about its parent, and a parent only knows the child's public properties and events. The other three are for state that many components need, which otherwise passes through every layer in between. ## Parent to child: properties A parent sets its child's properties in its template, as it would for any element. The child declares them in `static member properties`, and Lit re-renders it when they change. Bind a property with a dot, `.colors={colors}`, to pass any F# value: a list, a record, a function. An attribute binding without the dot turns the value into a string, which suits only strings and numbers that you would also write in HTML. [Binding values](https://roboz0r.github.io/Firelight/guides/templates/#binding-values) covers the difference. Lit re-renders a child when a property gets a new value, compared by identity. An F# list or record is immutable, so a change always makes a new value, and the child always sees it. ## Child to parent: events A child reports what happened by dispatching an event from itself. It doesn't call its parent: any ancestor that cares listens, with the same `@name` binding as for `click`. ```fsharp open Browser.Types open Firelight let pick (host: LitElement) (color: string) = host.dispatch (Event.customEvent ("color-picked", color)) ``` `Event.customEvent` makes a `CustomEvent` with the value as its `detail`. It sets `bubbles` and `composed`, so the event travels up the tree and out of the child's shadow root, to listeners on any ancestor. In the parent's template, `Ev.custom<'T>` types the listener's event as a `CustomEvent<'T>`, whose `detail` is a `'T option`. This demo has both directions. The parent owns the colour and passes it to `` as properties. The picker raises `color-picked` when a swatch is clicked. Pick a colour, and the parent's text follows the event. Then press Reset: the parent sets the picker's `selected` property, and the picker follows. ```fsharp module Snippets.SwatchPicker open Fable.Core open Fable.Core.JsInterop open Browser.Types open Firelight open type Firelight.Lit let private swatch (color: string) (selected: bool) (pick: unit -> unit) = html $"""""" /// The child: shows the colours its parent gives it, and raises color-picked when one is clicked. [] type SwatchPicker() = inherit LitElement() static member properties = PropertyDeclarations.create [ "colors", PropertyDeclaration(attribute = false) "selected", PropertyDeclaration() ] static member styles = css $$""" :host { display: flex; gap: 0.5rem; } button { width: 2rem; height: 2rem; border: 2px solid transparent; border-radius: 50%; cursor: pointer; } button[aria-pressed="true"] { outline: 3px solid var(--fg); outline-offset: 2px; } """ member val colors: string list = [] with get, set member val selected = "" with get, set member this.Pick(color: string) = this.selected <- color this.dispatch (Event.customEvent ("color-picked", color)) override this.render() = html $"""{[ for color in this.colors -> swatch color (color = this.selected) (fun () -> this.Pick color) ]}""" /// The parent: owns the colour, passes it down as properties and listens for the child's event. [] type SwatchParent() = inherit LitElement() static member properties = PropertyDeclarations.create [ "color", PropertyDeclaration(state = true) ] static member styles = css $$""" :host { display: grid; gap: 0.75rem; justify-items: start; } p { margin: 0; } button { font: inherit; padding: 0.3rem 0.6rem; } """ member val color = "teal" with get, set override this.render() = html $"""

    The parent's colour is {this.color}.

    (fun e -> e.detail |> Option.iter (fun c -> this.color <- c))}> """ defineElement "my-swatch-picker" defineElement "my-swatch-parent" ``` ```html ``` The picker sets its own `selected` before raising the event, as a native ``; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Browser.Types open Firelight open type Firelight.Lit [] type FocusField() = inherit LitElement() static member shadowRootOptions = jsOptions (fun o -> o.mode <- EncapsulationMode.Open o.delegatesFocus <- true) override _.render() = html $"""""" defineElement "focus-field" ``` Lit's default is `{ mode: 'open' }`, so setting `mode` and `delegatesFocus` gives the same options. ## Rendering without shadow DOM `LightDomElement` is a `LitElement` whose `createRenderRoot` returns the element itself. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; @customElement('page-note') export class PageNote extends LitElement { protected createRenderRoot() { return this; } render() { return html`

    Styled by the page

    `; } } ``` **Firelight** ```fsharp open Fable.Core open Firelight open type Firelight.Lit [] type PageNote() = inherit LightDomElement() override _.render() = html $"""

    Styled by the page

    """ defineElement "page-note" ``` Without a shadow root, the page's stylesheets apply, `static styles` doesn't, and `` has nothing to show. ## No direct equivalent - **`super.styles`.** Name the base class: `BaseCard.styles`. - **CSS module scripts.** `import sheet from './x.css' with { type: 'css' }` needs an import attribute, which Fable can't emit. Import the text with `?inline`, as above. - **Spreading objects.** `{ ...LitElement.shadowRootOptions, delegatesFocus: true }` has no F# syntax. Set the fields with `jsOptions`, or merge with `JS.Constructors.Object.assign`. Firelight binds the rest of Lit's styles API: `unsafeCSS`, `adoptStyles`, `getCompatibleStyle` and `supportsAdoptingStyleSheets`. For classes and inline styles that change with state, see `classMap` and `styleMap` in [Templates](https://roboz0r.github.io/Firelight/from-lit/templates/#built-in-directives). ## Mistakes that compile | You wrote | What happens | Write instead | |---|---|---| | `css $$"""p { color: {{color}}; }"""` with a string | Lit throws: Value passed to 'css' function must be a 'css' function result. Debug builds throw first, naming the hole: css: the hole after "p { color: " holds the string … | `{{unsafeCSS color}}`, for text you wrote | | `{color}` in `$$"""` | The stylesheet holds the text `{color}`, which the browser can't parse | `{{color}}` | | `static member styles` on a `LightDomElement` | No effect: there is no shadow root to adopt them | The page's stylesheet | # Lifecycle Source: https://roboz0r.github.io/Firelight/from-lit/lifecycle/ The callbacks are Lit's, overridden with `override`. Call `base` where TypeScript calls `super`, and read `changedProperties` as a dictionary keyed by property name. - [Lit docs: Lifecycle](https://lit.dev/docs/components/lifecycle/) ## Connecting and disconnecting Listeners on `window` or `document` go on in `connectedCallback` and come off in `disconnectedCallback`, as in Lit. F# has no arrow-function field to hand both calls the same function, so `Ev.listen` adds the listener and returns the function that removes it. **Lit** ```ts import { LitElement, html, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('esc-notice') export class EscNotice extends LitElement { @state() private shown = true; private onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') this.shown = false; }; connectedCallback() { super.connectedCallback(); window.addEventListener('keydown', this.onKey); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('keydown', this.onKey); } render() { return this.shown ? html`

    Press Escape to close

    ` : nothing; } } ``` **Firelight** ```fsharp open Fable.Core open Browser open Browser.Types open Firelight open type Firelight.Lit [] type EscNotice() = inherit LitElement() let mutable stop = ignore static member properties = PropertyDeclarations.create [ "shown", PropertyDeclaration(state = true) ] member val private shown = true with get, set override this.connectedCallback() = base.connectedCallback () let onKey (e: KeyboardEvent) = if e.key = "Escape" then this.shown <- false stop <- Ev.listen window "keydown" onKey override this.disconnectedCallback() = base.disconnectedCallback () stop () override this.render() = if this.shown then html $"

    Press Escape to close

    " else nothing defineElement "esc-notice" ``` `Ev.listen` takes a handler of any event type and casts each event to it, unchecked, like `as` in TypeScript: nothing compares `KeyboardEvent` with the name `"keydown"`. ## Computing values before render `willUpdate` gets the changed properties as a `Dictionary`: `ContainsKey` where TypeScript calls `has`, and an indexer for the old value. **Lit** ```ts import { LitElement, html } from 'lit'; import type { PropertyValues } from 'lit'; import { customElement, property } from 'lit/decorators.js'; const letter = (s: string) => s.slice(0, 1); @customElement('name-badge') export class NameBadge extends LitElement { @property() first = ''; @property() last = ''; private initials = ''; willUpdate(changed: PropertyValues) { if (changed.has('first') || changed.has('last')) { this.initials = letter(this.first) + letter(this.last); } } render() { return html`${this.initials} ${this.first}`; } } ``` **Firelight** ```fsharp open Fable.Core open Firelight open type Firelight.Lit let letter (s: string) = if s = "" then "" else s.Substring(0, 1) [] type NameBadge() = inherit LitElement() let mutable initials = "" static member properties = PropertyDeclarations.create [ "first", PropertyDeclaration() "last", PropertyDeclaration() ] member val first = "" with get, set member val last = "" with get, set override this.willUpdate(changed) = let has name = changed.ContainsKey name if has "first" || has "last" then initials <- letter this.first + letter this.last override this.render() = html $"{initials} {this.first}" defineElement "name-badge" ``` `PropertyValues` checks the names you pass to `has`. The F# keys are plain strings, and the old values are `obj`: `unbox changed["first"]`. ## After the first render `firstUpdated` runs once the element's DOM exists, so it's where a ref first has a value. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, query } from 'lit/decorators.js'; @customElement('search-start') export class SearchStart extends LitElement { @query('input') private input!: HTMLInputElement; firstUpdated() { this.input.focus(); } render() { return html``; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type SearchStart() = inherit LitElement() let input = createRef () override _.firstUpdated(_) = input.value |> Option.iter _.focus() override _.render() = html $"""""" defineElement "search-start" ``` ## Waiting for an update `updateComplete` is a `JS.Promise`. Fable.Promise's `promise { }` waits for it with `let!`, where TypeScript uses `await`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('field-list') export class FieldList extends LitElement { @state() private count = 1; private async add() { this.count++; await this.updateComplete; const all = this.renderRoot.querySelectorAll('input'); all[all.length - 1].focus(); } render() { const fields = Array.from( { length: this.count }, () => html``); return html` ${fields}`; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type FieldList() = inherit LitElement() static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration(state = true) ] member val private count = 1 with get, set member private this.FocusLast() = let all = this.queryAll "input" all.[all.Length - 1].focus () member private this.Add() = this.count <- this.count + 1 promise { let! _ = this.updateComplete this.FocusLast() } |> Promise.start override this.render() = let add _ = this.Add() let fields = List.init this.count (fun _ -> html $"") html $""" {fields}""" defineElement "field-list" ``` ## Every callback | Lit | Firelight | |---|---| | `connectedCallback()`, with `super.connectedCallback()` | `override this.connectedCallback()`, with `base.connectedCallback ()` | | `disconnectedCallback()` | `override this.disconnectedCallback()`, with `base.disconnectedCallback ()` | | `attributeChangedCallback(name, old, value)` | `override this.attributeChangedCallback(name, old, value)`; old and new values are `string option` | | `shouldUpdate(changed)` | `override _.shouldUpdate(changed)`, returning a `bool` | | `willUpdate(changed)` | `override this.willUpdate(changed)` | | `update(changed)`, with `super.update(changed)` | `override this.update(changed)`, with `base.update changed` | | `render()` | `override this.render()` | | `firstUpdated(changed)` | `override this.firstUpdated(changed)` | | `updated(changed)` | `override this.updated(changed)` | | `getUpdateComplete()` | `override this.getUpdateComplete()` | | `scheduleUpdate()` | `override this.scheduleUpdate()`, returning an `obj` (see below) | | `performUpdate()`, with `super.performUpdate()` | `override this.performUpdate()`, with `base.performUpdate ()` | | `createRenderRoot()` | `override this.createRenderRoot()`, returning an `obj`, such as `this`; or inherit [`LightDomElement`](https://roboz0r.github.io/Firelight/from-lit/styles/#rendering-without-shadow-dom) | | `requestUpdate()` | `this.requestUpdate ()` | | `updateComplete`, `hasUpdated`, `isUpdatePending` | The same names, as members | ## No direct equivalent - **Arrow-function fields.** F# compiles a `let` function or a `member val` holding a function to a method, and passes a new wrapper each time it's used. `removeEventListener` then can't find the listener you added, so the listener stays. Add it with `Ev.listen`, which returns its own remover, as `EscNotice` does. - **`await super.scheduleUpdate()` in a deferred update.** `scheduleUpdate` returns an `obj`, Lit's `void | Promise`: return `box` of a promise to make `updateComplete` wait for it. F# doesn't allow `base` inside a lambda or a `promise { }` ("'base' is being used. This is only allowed in the direct implementation of members since they could escape their object scope"), so call the base method through a member of your own, such as `member this.ScheduleNow() = base.scheduleUpdate ()`, from inside the promise. - **`PropertyValues`.** The keys of `changed` are unchecked strings. - **`async` and `await`.** Use Fable.Promise's `promise { }`, with `let!` for `await`. F#'s own `async` isn't a promise. ## Mistakes that compile | You wrote | What happens | Write instead | |---|---|---| | `window.removeEventListener ("keydown", this.OnKey)` | The listener stays: each use of `this.OnKey` is a new function | `Ev.listen`, as above | | No `base.connectedCallback ()` | Lit doesn't create the shadow root or start updating, so nothing renders | Call `base` first | | `changed.ContainsKey "frist"` | Never true | The property's exact name | The [Lifecycle guide](https://roboz0r.github.io/Firelight/guides/lifecycle/) covers the Firelight side in more depth. # Events Source: https://roboz0r.github.io/Firelight/from-lit/events/ `@event` bindings don't change. The handler is an F# function: `Ev` gives it its event's type, and `Event.customEvent` builds a custom event that crosses shadow roots. - [Lit docs: Events](https://lit.dev/docs/components/events/) ## Listening in a template Lit calls a listener method with `this` set to the element. In F#, pass the method itself: `Ev.keyboard this.OnKey` turns it into a function and tells F# the event is a `KeyboardEvent`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('quick-add') export class QuickAdd extends LitElement { @state() private items: string[] = []; private onKey(e: KeyboardEvent) { const input = e.target as HTMLInputElement; const text = input.value; if (e.key === 'Enter' && text !== '') { this.items = [...this.items, text]; input.value = ''; } } render() { const item = (i: string) => html`
  • ${i}
  • `; return html`
      ${this.items.map(item)}
    `; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type QuickAdd() = inherit LitElement() static member properties = PropertyDeclarations.create [ "items", PropertyDeclaration(state = true) ] member val private items: string[] = [||] with get, set member private this.OnKey(e: KeyboardEvent) = let input = e.target :?> HTMLInputElement let text = input.value if e.key = "Enter" && text <> "" then this.items <- Array.append this.items [| text |] input.value <- "" override this.render() = let item i = html $"
  • {i}
  • " html $"""
      {this.items |> Array.map item}
    """ defineElement "quick-add" ``` `Ev.mouse`, `Ev.pointer`, `Ev.focus`, `Ev.input` and the others do the same for their event types. `Ev.value` and `Ev.checked'` pass the handler the field's value instead of the event; see [Templates](https://roboz0r.github.io/Firelight/from-lit/templates/#expressions). Nothing checks the type against the event's name: `@click={Ev.keyboard ...}` compiles. ## Dispatching a custom event `Event.customEvent` sets `bubbles` and `composed` to `true` for you. `this.dispatch` is `dispatchEvent` without its result, whether the event went uncancelled, which F# would make you `ignore`. For an event a listener can cancel, pass `cancelable = true` and call `dispatchEvent`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; @customElement('swatch-row') export class SwatchRow extends LitElement { private pick(color: string) { this.dispatchEvent(new CustomEvent('color-picked', { detail: color, bubbles: true, composed: true, })); } render() { return ['red', 'teal'].map((c) => html` `); } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type SwatchRow() = inherit LitElement() member private this.Pick(color: string) = this.dispatch (Event.customEvent ("color-picked", color)) override this.render() = let swatch c = let pick _ = this.Pick c html $"""""" html $"""{[ "red"; "teal" ] |> List.map swatch}""" defineElement "swatch-row" ``` `render` in TypeScript can return the array itself. F#'s `render` returns a `ChildRenderable`, which a list isn't, so the list goes in a template. ## Listening for a custom event `Ev.custom` types the event as a `CustomEvent<'T>`. Its `detail` is a `'T option`, where TypeScript has a plain `T`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('color-chooser') export class ColorChooser extends LitElement { @state() private color = 'none'; private onPicked(e: CustomEvent) { this.color = e.detail; } render() { return html`

    Picked: ${this.color}

    `; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ColorChooser() = inherit LitElement() static member properties = PropertyDeclarations.create [ "color", PropertyDeclaration(state = true) ] member val private color = "none" with get, set member private this.OnPicked(e: CustomEvent) = e.detail |> Option.iter (fun c -> this.color <- c) override this.render() = html $"""

    Picked: {this.color}

    """ defineElement "color-chooser" ``` Nothing checks that the event's `detail` is the type you name, in either language. ## Listening on the element itself The F# class has no `addEventListener`: [Fable](https://fable.io/)'s `HTMLElement` is an interface, which `LitElement` can't inherit. `this.element` is the component as an `HTMLElement`, a cast that compiles to `this`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('click-total') export class ClickTotal extends LitElement { @state() private clicks = 0; constructor() { super(); this.addEventListener('click', () => this.clicks++); } render() { return html` (${this.clicks})`; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ClickTotal() as this = inherit LitElement() do let count _ = this.Count() this.element.addEventListener ("click", count) static member properties = PropertyDeclarations.create [ "clicks", PropertyDeclaration(state = true) ] member val private clicks = 0 with get, set member private this.Count() = this.clicks <- this.clicks + 1 override this.render() = html $" ({this.clicks})" defineElement "click-total" ``` `as this` names the instance in the constructor, where `do` runs. ## No direct equivalent - **Event options.** `@eventOptions` becomes a `LitEventListener`; see [Decorators](https://roboz0r.github.io/Firelight/from-lit/decorators/#eventoptions). - **Listeners on `window` or `document`.** Add them in `connectedCallback` with `Ev.listen`, and call the function it returns in `disconnectedCallback`; see [Lifecycle](https://roboz0r.github.io/Firelight/from-lit/lifecycle/#connecting-and-disconnecting). - **Typed `addEventListener`.** TypeScript picks the event type from the event's name. Fable's `addEventListener` takes an `Event -> unit`, so cast inside the handler, or use `Ev.listen`, which takes a handler of any event type and casts for you, unchecked. - **`detail`.** It's an option in F#: `None` when the event has none. - **Defaults.** `new CustomEvent(...)` doesn't bubble or cross shadow roots unless you say so. `Event.customEvent` does both unless you pass `bubbles = false` or `composed = false`. The [Events guide](https://roboz0r.github.io/Firelight/guides/events/) covers the Firelight side in more depth. # Decorators Source: https://roboz0r.github.io/Firelight/from-lit/decorators/ F# has no decorators. Each of Lit's becomes a static member, a function call, or a line or two in a member. Nothing needs configuring: there is no `experimentalDecorators` and no `accessor`. - [Lit docs: Decorators](https://lit.dev/docs/components/decorators/) - [Lit API: Decorators](https://lit.dev/docs/api/decorators/) | Lit | Firelight | |---|---| | `@customElement('x-tag')` | `defineElement "x-tag"` after the type ([Components](https://roboz0r.github.io/Firelight/from-lit/components/#defining-a-component)) | | `@property(options)` | An entry in `static member properties`, and a `member val` ([Components](https://roboz0r.github.io/Firelight/from-lit/components/#reactive-properties)) | | `@state()` | `PropertyDeclaration<'T>(state = true)` ([Components](https://roboz0r.github.io/Firelight/from-lit/components/#internal-state)) | | `@query('input')` | A `ref` to the element | | `@query('input')`, by selector | `this.query "input"`, an option | | `@queryAll('li')` | `this.queryAll "li"`, an array | | `@queryAsync('input')` | Wait for `this.updateComplete`, then query | | `@queryAssignedElements()` | `slot.assignedElements ()`, the slot from `Ev.slot` or `this.query` | | `@queryAssignedNodes()` | `slot.assignedNodes ()` | | `@eventOptions({ passive: true })` | `LitEventListener(handler, passive = true)` | | `@provide`, `@consume` | `ContextProvider`, `ContextConsumer` ([Context](https://roboz0r.github.io/Firelight/from-lit/context/)) | ## @query A `ref` does the job of `@query` without a selector. Its `value` is an option: `None` until the element has rendered. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, query } from 'lit/decorators.js'; @customElement('search-box') export class SearchBox extends LitElement { @query('input') input!: HTMLInputElement; render() { return html` `; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type SearchBox() = inherit LitElement() let input = createRef () override _.render() = let focus _ = input.value |> Option.iter _.focus() html $""" """ defineElement "search-box" ``` To query by selector instead, as `@query` does, call `this.query "input"` in an event handler or after an update. It searches the render root, as the decorator does, and returns `None` when nothing matches. `this.queryAll` returns every match, as an array. The element type is yours to get right: nothing checks it. ## @queryAssignedElements Firelight binds `HTMLSlotElement`, which [Fable](https://fable.io/)'s browser bindings lack. `Ev.slot` hands the handler the `` that `@slotchange` is bound on, so it can call `assignedElements` there, when the count changes. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, queryAssignedElements, state, } from 'lit/decorators.js'; @customElement('item-count') export class ItemCount extends LitElement { @queryAssignedElements() items!: Element[]; @state() private count = 0; render() { return html`

    ${this.count} items

    (this.count = this.items.length)}>`; } } ``` **Firelight** ```fsharp open Fable.Core open Firelight open type Firelight.Lit [] type ItemCount() = inherit LitElement() static member properties = PropertyDeclarations.create [ "count", PropertyDeclaration(state = true) ] member val private count = 0 with get, set override this.render() = let recount (slot: HTMLSlotElement) = this.count <- slot.assignedElements().Length html $"""

    {this.count} items

    """ defineElement "item-count" ``` `Ev.slot` passes the listener's `currentTarget`, so bind it on the `` itself: `slotchange` bubbles, and on a parent element `Ev.slot` would pass the parent. Outside a `slotchange` handler, find the slot with `this.query`, as the decorator's `slot` option does. Its other options become an argument and a filter: `assignedElements (flatten = true)` for `{ flatten: true }`, and `Array.filter` with `matches` for `selector`: ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ActionBar() = inherit LitElement() // @queryAssignedElements({ slot: 'actions', selector: 'button', flatten: true }) member this.buttons: Element[] = match this.query "slot[name=actions]" with | Some slot -> slot.assignedElements (flatten = true) |> Array.filter (fun e -> e.matches "button") | None -> [||] override _.render() = html $"""""" defineElement "action-bar" ``` A component that forwards its own slot, ``, assigns that `` to `actions`; `flatten = true` replaces it with the elements assigned to it. `assignedNodes` takes the same argument, and includes text nodes. ## @eventOptions `LitEventListener` wraps a handler with the options. Bind it where you would bind the method. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, eventOptions, state, } from 'lit/decorators.js'; @customElement('scroll-meter') export class ScrollMeter extends LitElement { @state() private top = 0; @eventOptions({ passive: true }) private onScroll(e: Event) { this.top = (e.target as HTMLElement).scrollTop; } render() { return html`

    Scrolled ${this.top}px

    `; } } ``` **Firelight** ```fsharp open Fable.Core open Browser.Types open Firelight open type Firelight.Lit [] type ScrollMeter() = inherit LitElement() static member properties = PropertyDeclarations.create [ "top", PropertyDeclaration(state = true) ] member val private top = 0.0 with get, set member private this.OnScroll(e: Event) = this.top <- (e.target :?> HTMLElement).scrollTop override this.render() = let onScroll = LitEventListener( (fun e -> this.OnScroll e), passive = true) html $"""

    Scrolled {this.top}px

    """ defineElement "scroll-meter" ``` `:?>` to an interface type such as `HTMLElement` compiles to nothing in JavaScript, like `as` in TypeScript: it isn't checked when it runs. ## No direct equivalent - **Attributes don't act.** An F# attribute is metadata. `[]` is read by the compiler; Firelight reads no attributes of its own, so there is no `[]` to put on a `member val`. - **`@query('input', true)`.** A cached query keeps the first element it finds. A `ref` needs no cache, and follows the element if a render replaces it. - **`@queryAsync`.** There is no member that returns a promise of the element. Wait for `this.updateComplete`, then read the ref or query. # Templates Source: https://roboz0r.github.io/Firelight/from-lit/templates/ A template is an F# interpolated string passed to `html`. A hole is `{value}` where TypeScript has `${value}`, and the bindings, directives and update rules are Lit's. - [Lit docs: Templates](https://lit.dev/docs/templates/overview/) - [Lit docs: Built-in directives](https://lit.dev/docs/templates/directives/) - [Firelight guide: Templates](https://roboz0r.github.io/Firelight/guides/templates/) The [Templates guide](https://roboz0r.github.io/Firelight/guides/templates/) explains how F# strings become Lit templates and the mistakes to avoid. This page maps what you write in TypeScript to what you write in F#. ## Expressions Every kind of binding keeps its prefix: none for a child or an attribute, `.` for a property, `?` for a boolean attribute and `@` for an event. `Ev.checked'` and `Ev.value` hand the handler the element's `checked` or `value`, which replaces the cast to `HTMLInputElement`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; @customElement('gift-note') export class GiftNote extends LitElement { @state() private gift = false; @state() private note = ''; render() { return html` (this.note = (e.target as HTMLInputElement).value)}>

    ${this.gift ? `Note: ${this.note}` : 'No gift'}

    `; } } ``` **Firelight** ```fsharp open Fable.Core open Firelight open type Firelight.Lit [] type GiftNote() = inherit LitElement() static member properties = PropertyDeclarations.create [ "gift", PropertyDeclaration(state = true) "note", PropertyDeclaration(state = true) ] member val private gift = false with get, set member val private note = "" with get, set override this.render() = let setGift c = this.gift <- c let setNote v = this.note <- v let summary = if this.gift then $"Note: {this.note}" else "No gift" html $"""

    {summary}

    """ defineElement "gift-note" ``` The F# template is in triple quotes, `$"""`, so its attributes can use `"` and its holes can hold strings. ## Conditionals `if` and `match` do the work of the ternary operator and of `choose`. Each returns a template or `nothing`, which fits wherever a template does. **Lit** ```ts import { LitElement, html, nothing } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; @customElement('save-status') export class SaveStatus extends LitElement { @property() status = 'idle'; @property({ type: Boolean }) dirty = false; render() { const unsaved = this.dirty ? html`Unsaved changes` : nothing; const status = choose(this.status, [ ['saving', () => html`

    Saving…

    `], ['error', () => html`

    Failed

    `], ], () => html`

    All saved

    `); return html`${unsaved}${status}`; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open type Firelight.Lit [] type SaveStatus() = inherit LitElement() static member properties = PropertyDeclarations.create [ "status", PropertyDeclaration() "dirty", PropertyDeclaration() ] member val status = "idle" with get, set member val dirty = false with get, set override this.render() = let unsaved = if this.dirty then html $"Unsaved changes" else nothing let status = match this.status with | "saving" -> html $"

    Saving…

    " | "error" -> html $"""

    Failed

    """ | _ -> html $"

    All saved

    " html $"{unsaved}{status}" defineElement "save-status" ``` Firelight binds `choose` and `when` too, as `choose` and `when'` (`when` is an F# keyword), but `match` and `if` read better and the compiler checks them. ## Lists `List.map` replaces `Array.map`, and `repeat` keeps its three arguments. Its key and template functions take the item and its index, so `fun t _ ->` ignores the index. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; interface Task { id: number; text: string } @customElement('task-list') export class TaskList extends LitElement { @state() private tasks: Task[] = [ { id: 1, text: 'Water plants' }, { id: 2, text: 'Post letter' }, ]; private finish(id: number) { this.tasks = this.tasks.filter((t) => t.id !== id); } private row(t: Task) { const done = () => this.finish(t.id); return html`
  • ${t.text}
  • `; } render() { const rows = repeat( this.tasks, (t) => t.id, (t) => this.row(t)); return html`
      ${rows}
    `; } } ``` **Firelight** ```fsharp open Fable.Core open Firelight open type Firelight.Lit type Task = { Id: int; Text: string } [] type TaskList() = inherit LitElement() static member properties = PropertyDeclarations.create [ "tasks", PropertyDeclaration(state = true) ] member val private tasks = [ { Id = 1; Text = "Water plants" } { Id = 2; Text = "Post letter" } ] with get, set member private this.Finish(id: int) = this.tasks <- this.tasks |> List.filter (fun t -> t.Id <> id) member private this.Row(t: Task) = let finish _ = this.Finish t.Id html $"""
  • {t.Text}
  • """ override this.render() = let rows = repeat ( this.tasks, (fun t _ -> t.Id), fun t _ -> this.Row t ) html $"
      {rows}
    " defineElement "task-list" ``` Without keys, `this.tasks |> List.map this.Row` in the hole renders the same list. The Templates guide has a [demo of the difference](https://roboz0r.github.io/Firelight/guides/templates/#keyed-lists-with-repeat): type into a row, then remove the one above it. `List.filter` returns a new list, so Lit sees the change, as it does for `Array.filter`. ## Built-in directives Each directive is a static member of `Lit`, so `open type Firelight.Lit` brings them all in scope, with no import per directive. `classMap` and `styleMap` take their objects through `ClassInfo.create` and `StyleInfo.create`. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; @customElement('fill-bar') export class FillBar extends LitElement { @property({ type: Number }) value = 0; render() { const full = this.value >= 100; return html`
    `; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open type Firelight.Lit [] type FillBar() = inherit LitElement() static member properties = PropertyDeclarations.create [ "value", PropertyDeclaration() ] member val value = 0.0 with get, set override this.render() = let full = this.value >= 100.0 let classes = ClassInfo.create [ "bar", true; "full", full ] let percent = $"{this.value}%%" let width = StyleInfo.create [ "width", Some percent ] html $"""
    """ defineElement "fill-bar" ``` `%%` is a literal `%` in an F# interpolated string. A style whose value is `None` is removed, where TypeScript would use `undefined` or `null`. | Lit | Firelight | |---|---| | `classMap({ on: x })` | `classMap (ClassInfo.create [ "on", x ])` | | `styleMap({ color: c })` | `styleMap (StyleInfo.create [ "color", Some c ])` | | `repeat(items, key, tpl)` | `repeat (items, (fun i _ -> key), fun i _ -> tpl)` | | `ifDefined(x)` | `ifDefined x`, where `x` is an option | | `live(x)` | `live x` | | `ref(r)`, `createRef()` | `ref r`, `createRef ()` | | `guard([a, b], () => t)` | `guard ([\| a; b \|], fun () -> t)` | | `cache(t)`, `keyed(k, t)` | `cache t`, `keyed (k, t)` | | `until(p, placeholder)` | `until (p, placeholder)` | | `join(xs, sep)` | `join (xs, sep)` | | `when(c, a, b)` | `when' (c, a, b)` | | `choose`, `map`, `range` | `choose`, `map`, `range` | | `asyncAppend`, `asyncReplace` | `asyncAppend`, `asyncReplace` | | `unsafeHTML`, `unsafeSVG` | `unsafeHTML`, `unsafeSVG` | | `templateContent(t)` | `templateContent t` | `open type Firelight.Lit` hides F#'s own `ref`; write `Operators.ref` for a reference cell. ## Static values `literal` and the static `html` live in `StaticHTML`. Call them by their full names, so the plain `html` stays in scope. **Lit** ```ts import { LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { html, literal } from 'lit/static-html.js'; @customElement('section-title') export class SectionTitle extends LitElement { @property({ type: Number }) level = 2; render() { const tag = this.level === 3 ? literal`h3` : literal`h2`; return html`<${tag}>`; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open type Firelight.Lit [] type SectionTitle() = inherit LitElement() static member properties = PropertyDeclarations.create [ "level", PropertyDeclaration() ] member val level = 2 with get, set override this.render() = let tag = if this.level = 3 then StaticHTML.literal $"h3" else StaticHTML.literal $"h2" StaticHTML.html $"<{tag}>" defineElement "section-title" ``` ## Rendering outside a component `render` is a static member of `Lit` too. It returns the `RootPart`, which F# makes you use or `ignore`. **Lit** ```ts import { html, render } from 'lit'; const greeting = (name: string) => html`

    Hello, ${name}

    `; render(greeting('Ada'), document.body); ``` **Firelight** ```fsharp open Browser open Firelight open type Firelight.Lit let greeting (name: string) = html $"

    Hello, {name}

    " render (greeting "Ada", document.body) |> ignore ``` ## No direct equivalent - **Custom directives.** Lit's `directive()`, `Directive` and `AsyncDirective` aren't bound, so you can't write a directive class in F#. A function that returns a template covers most uses. For one that needs Lit's parts, write the directive in JavaScript and import it with `[]`. - **`unsafeMathML`.** Not bound. `mathml` templates are. - **Template type checking.** `lit-analyzer` and its editor plugin check bindings in TypeScript templates. Nothing reads the HTML inside an F# string: a hole's type is `obj`, so any value compiles, and nothing checks an `Ev` handler against the event's name. - **Format specifiers.** In TypeScript you format inside the hole: `${price.toFixed(2)}`. In F#, `{price:F2}` compiles but Lit never sees the format, and Debug builds throw. Write `{price.ToString "F2"}`. # Composition Source: https://roboz0r.github.io/Firelight/from-lit/composition/ A reactive controller is an F# class that implements `ReactiveController` and adds itself to its host, as in Lit. Mixins don't carry over: F# can't build a class from a function, so shared behaviour goes in a base class or a controller. - [Lit docs: Controllers](https://lit.dev/docs/composition/controllers/) - [Lit docs: Mixins](https://lit.dev/docs/composition/mixins/) - [Lit docs: Component composition](https://lit.dev/docs/composition/component-composition/) Composing components, with child elements in a template, properties passed down and events sent up, works as in Lit: see [Components](https://roboz0r.github.io/Firelight/from-lit/components/), [Events](https://roboz0r.github.io/Firelight/from-lit/events/) and the [Component communication guide](https://roboz0r.github.io/Firelight/guides/communication/). ## Reactive controllers The F# controller implements all four callbacks, where TypeScript leaves out the ones it doesn't need. `as this` lets the constructor add the controller to its host. **Lit** ```ts import { LitElement, html } from 'lit'; import type { ReactiveController, ReactiveControllerHost, } from 'lit'; import { customElement } from 'lit/decorators.js'; export class WidthController implements ReactiveController { width = 0; constructor(private host: ReactiveControllerHost) { host.addController(this); } private onResize = () => { this.width = window.innerWidth; this.host.requestUpdate(); }; hostConnected() { this.width = window.innerWidth; this.host.requestUpdate(); window.addEventListener('resize', this.onResize); } hostDisconnected() { window.removeEventListener('resize', this.onResize); } } @customElement('width-label') export class WidthLabel extends LitElement { private size = new WidthController(this); render() { const wide = this.size.width >= 600; const label = wide ? 'Wide' : 'Narrow'; return html`${label} window`; } } ``` **Firelight** ```fsharp open Fable.Core open Browser open Browser.Types open Firelight open type Firelight.Lit type WidthController(host: ReactiveControllerHost) as this = inherit ReactiveControllerBase() let mutable width = 0.0 let mutable stop = ignore do host.addController this member _.Width = width member private _.Listen() = let onResize (_: Event) = width <- window.innerWidth host.requestUpdate () width <- window.innerWidth host.requestUpdate () stop <- Ev.listen window "resize" onResize override this.hostConnected() = this.Listen() override _.hostDisconnected() = stop () [] type WidthLabel() as this = inherit LitElement() let size = WidthController(this) override _.render() = let wide = size.Width >= 600.0 let label = if wide then "Wide" else "Narrow" html $"{label} window" defineElement "width-label" ``` `ReactiveControllerBase` stands for `implements ReactiveController`: override the callbacks you need, and the others do nothing. The controller doesn't need `[]`: Lit calls only the four callbacks, which the base class implements under their own names. The listener is added with `Ev.listen` when the host connects, and its remover kept, as on [Lifecycle](https://roboz0r.github.io/Firelight/from-lit/lifecycle/#connecting-and-disconnecting). ## Mixins A mixin adds the same members to classes with different bases. F# has one base class per class, so a behaviour you'd write as one mixin becomes a base class. Several, combined freely, become controllers. **Lit** ```ts import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; type Constructor = new (...args: any[]) => T; const Highlightable = >( Base: T, ) => { class Highlighted extends Base { static styles = css` :host([highlighted]) { background: #fff3bf; } `; @property({ type: Boolean, reflect: true }) highlighted = false; } return Highlighted; }; @customElement('tip-text') export class TipText extends Highlightable(LitElement) { render() { return html``; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open type Firelight.Lit [] type Highlightable() = inherit LitElement() static member styles = css $$""" :host([highlighted]) { background: #fff3bf; } """ static member properties = PropertyDeclarations.create [ "highlighted", PropertyDeclaration( reflect = true) ] member val highlighted = false with get, set [] type TipText() = inherit Highlightable() override _.render() = html $"" defineElement "tip-text" ``` A subclass can declare `static member properties` of its own; Lit merges them with the base class's, as it does in TypeScript. ## No direct equivalent - **Mixins.** No class expressions and no multiple inheritance. Use a base class for one behaviour, controllers for several. - **Lit's own mixins.** `SignalWatcher` is applied by `LitSignals.defineElement`; see [Signals](https://roboz0r.github.io/Firelight/from-lit/signals/). Firelight packages bind several of Lit's controllers: `ContextProvider` and `ContextConsumer` ([Context](https://roboz0r.github.io/Firelight/from-lit/context/)), `Task` as `LitTask` ([Tasks](https://roboz0r.github.io/Firelight/from-lit/tasks/)), the resize, intersection, mutation and performance controllers in [Firelight.Observers](https://roboz0r.github.io/Firelight/packages/observers/), and the animation controllers in [Firelight.Motion](https://roboz0r.github.io/Firelight/packages/motion/). The [Controllers guide](https://roboz0r.github.io/Firelight/guides/controllers/) covers writing your own. # Context Source: https://roboz0r.github.io/Firelight/from-lit/context/ `@provide` and `@consume` become `ContextProvider` and `ContextConsumer`, the controllers that `@lit/context` also exports. A context is a symbol branded with the type of its value, so a consumer gets that type back. - [Lit docs: Context](https://lit.dev/docs/data/context/) - [Package: Firelight.Context](https://roboz0r.github.io/Firelight/packages/context/) ## Providing and consuming The provider sets a new value with `setValue`, where the decorated field is assigned. To let other code set it, as a public `@provide` field allows, give the F# class a property whose setter calls `setValue`. The consumer's `value` is an option: `None` until a provider answers. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { consume, createContext, provide, } from '@lit/context'; type Units = 'metric' | 'imperial'; const unitsContext = createContext(Symbol('units')); @customElement('units-provider') export class UnitsProvider extends LitElement { @provide({ context: unitsContext }) private units: Units = 'metric'; private toggle() { this.units = this.units === 'metric' ? 'imperial' : 'metric'; } render() { return html` `; } } @customElement('run-distance') export class RunDistance extends LitElement { @consume({ context: unitsContext, subscribe: true }) units?: Units; render() { const label = this.units === 'imperial' ? '3.1 mi' : '5 km'; return html`${label}`; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open Firelight.Context open type Firelight.Lit type Units = | Metric | Imperial type UnitsContext = inherit Context inherit symbol let unitsContext: UnitsContext = LitContext.createContext (JS.Symbol "units") [] type UnitsProvider() = inherit LitElement() let mutable units = Metric let provider = ContextProvider( jsThis, ContextProvider.Options(unitsContext, units) ) member private _.Toggle() = units <- if units = Metric then Imperial else Metric provider.setValue units override this.render() = let toggle _ = this.Toggle() html $""" """ [] type RunDistance() = inherit LitElement() let units = ContextConsumer( jsThis, ContextConsumer.Options( unitsContext, subscribe = true) ) override _.render() = let label = if units.value = Some Imperial then "3.1 mi" else "5 km" html $"{label}" defineElement "units-provider" defineElement "run-distance" ``` The F# context's type lists `Context` and `symbol`, which `createContext` does for TypeScript. Both classes take the element as their host, here as `jsThis`, the JavaScript `this` of the object being constructed. They also accept the F# `this`, with `as this` on the type. The provider here doesn't show the value itself, so it doesn't call `this.requestUpdate()`. A provider that does show it needs to, as `setValue` only updates the consumers. ## Without decorators Lit code that already uses the controllers translates line by line: | Lit | Firelight | |---|---| | `new ContextProvider(this, { context, initialValue })` | `ContextProvider(jsThis, ContextProvider.Options(context, initialValue))` | | `provider.setValue(v)` | `provider.setValue v` | | `new ContextConsumer(this, { context, subscribe: true })` | `ContextConsumer(jsThis, ContextConsumer.Options(context, subscribe = true))` | | `{ context, callback: (v, unsubscribe) => ... }` | `ContextConsumer.Options(context, callback = fun v unsubscribe -> ...)` | | `consumer.value` | `consumer.value`, a `'T option` | | `new ContextRoot().attach(document.body)` | `ContextRoot().attach document.body` | ## No direct equivalent - **`@provide` and `@consume`.** F# has no decorators; use the controllers. - **`createContext(key)`.** The value type comes from an interface you declare, which inherits `Context<'T>` and `symbol`. The [Firelight.Context](https://roboz0r.github.io/Firelight/packages/context/) page has live examples, including a `ContextRoot`, and the [Todo demo](https://roboz0r.github.io/Firelight/demos/todo/) shares its Elmish state and `dispatch` through context. # Tasks Source: https://roboz0r.github.io/Firelight/from-lit/tasks/ `new Task(this, { ... })` becomes `LitTask(this, TaskConfig(...))`, named so it doesn't clash with .NET's `Task`. The task function returns a promise, and `render` picks the template for the task's status. - [Lit docs: Async tasks](https://lit.dev/docs/data/task/) - [Package: Firelight.Task](https://roboz0r.github.io/Firelight/packages/task/) ## Loading when arguments change The task runs again when `args` returns something new. Both sides import `loadUser` from the app's own `api.js`, which fetches a user and passes `signal` to `fetch` so that a stale request is cancelled. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { Task } from '@lit/task'; import { loadUser } from './api.js'; @customElement('user-card') export class UserCard extends LitElement { @property() userId = '1'; private user = new Task(this, { task: ([id], { signal }) => loadUser(id, signal), args: () => [this.userId], }); render() { return this.user.render({ pending: () => html`

    Loading…

    `, complete: (u) => html`

    ${u.name}

    `, error: (e) => html`

    Failed: ${e}

    `, }); } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Fetch open Firelight open Firelight.Task open type Firelight.Lit type User = abstract name: string [] let loadUser (id: string, signal: AbortSignal) : JS.Promise = jsNative let fetchUser (args: string[]) (opts: TaskFunctionOptions) : TaskResult = match args with | [| id |] -> !^(loadUser (id, opts.signal)) | _ -> initialState let showUser (u: User) = html $"

    {u.name}

    " let userStatus = StatusRenderer( pending = (fun () -> html $"

    Loading…

    "), complete = showUser, error = (fun e -> html $"

    Failed: {e}

    ") ) [] type UserCard() as this = inherit LitElement() let user = LitTask( this, TaskConfig( TaskFunction fetchUser, args = fun () -> [| this.userId |] ) ) static member properties = PropertyDeclarations.create [ "userId", PropertyDeclaration() ] member val userId = "1" with get, set override _.render() = html $"{user.render userStatus}" defineElement "user-card" ``` `[id]` becomes the array pattern `[| id |]`, in a `match`. On the parameter itself, `fun [| id |] opts -> ...`, it compiles with warning FS0025, "Incomplete pattern matches on this expression. For example, the value '[|_; _|]' may indicate a case not covered by the pattern(s)." `args` always returns one item, so the other case never runs; `initialState` is a harmless answer for it. The task function returns a `TaskResult`, an erased union of a value and a promise, and `!^` converts the promise to it. `!^` picks the case from the type it converts to, so `fetchUser` declares `TaskResult`. In a lambda passed straight to `TaskFunction`, as in `ReportButton` below, nothing says whether the result is the promise or the value it resolves to, and `!^` fails with FS0043, "A unique overload for method 'op_ErasedCast' could not be determined based on type information prior to this program point"; write `U2.Case2` there. `render` returns an option, `None` for a status with no template, and a hole renders `None` as nothing. The `signal` is typed as [Fable.Fetch](https://github.com/fable-compiler/fable-fetch)'s `AbortSignal`, hence `open Fetch`: Firelight.Task depends on Fable.Fetch, so the signal passes straight to its `fetch`. See [Cancelling requests](https://roboz0r.github.io/Firelight/packages/task/#cancelling-requests). ## Running by hand `autoRun = !^false` keeps the task from running on updates, and `run` starts it. **Lit** ```ts import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { Task } from '@lit/task'; import { buildReport } from './api.js'; @customElement('report-button') export class ReportButton extends LitElement { private report = new Task(this, { task: () => buildReport(), autoRun: false, }); render() { const build = () => this.report.run(); return html` ${this.report.render({ pending: () => html`

    Building…

    `, complete: (r) => html`

    ${r}

    `, })}`; } } ``` **Firelight** ```fsharp open Fable.Core open Fable.Core.JsInterop open Firelight open Firelight.Task open type Firelight.Lit [] let buildReport () : JS.Promise = jsNative let reportStatus = StatusRenderer( pending = (fun () -> html $"

    Building…

    "), complete = fun (r: string) -> html $"

    {r}

    " ) [] type ReportButton() as this = inherit LitElement() let report = LitTask( this, TaskConfig( TaskFunction(fun (_: obj[]) _ -> U2.Case2(buildReport ())), autoRun = !^false ) ) override _.render() = let build _ = report.run () |> ignore html $""" {report.render reportStatus}""" defineElement "report-button" ``` Before the first run the status is `INITIAL`, which neither side gives a template, so the hole shows nothing. ## The rest of the API | Lit | Firelight | |---|---| | `task.status === TaskStatus.COMPLETE` | `task.status = TaskStatus.COMPLETE` | | `task.value`, `task.error` | `task.value`, `task.error`, as options | | `task.run()`, `task.run([id])` | `task.run ()`, `task.run [\| id \|]` | | `task.abort()` | `task.abort ()` | | `await task.taskComplete` | `let! value = task.taskComplete`, in `promise { }` | | `argsEqual: deepArrayEquals` | `argsEqual = TaskArgsEqual(fun a b -> deepArrayEquals a b)` | | `initialValue`, `onComplete`, `onError` | The same names, as `TaskConfig` arguments | | `autoRun: 'afterUpdate'` | `autoRun = !^"afterUpdate"` | ## No direct equivalent - **The name.** `Task` is `LitTask` in F#. - **Destructuring `[id]`.** An array pattern does it, in a `match` with a case for any other length: `match args with [| id |] -> ... | _ -> initialState`. - **Number arguments.** `args` must return a JavaScript array, and Fable compiles an `int[]` or `float[]` to a typed array, so Lit throws "The args function must return an array". Return a `string[]`, an `obj[]`, or an F# tuple, which [Fable](https://fable.io/) compiles to an array: `fun () -> this.page, this.size`. - **`async` task functions.** Return a promise. Write one with Fable.Promise's `promise { ... }`, where `let!` awaits a promise as `await` does; F#'s own `async` isn't a promise. Fable.Promise comes with Firelight.Task, through Fable.Fetch. The [Firelight.Task](https://roboz0r.github.io/Firelight/packages/task/) page has a live example. # Signals Source: https://roboz0r.github.io/Firelight/from-lit/signals/ `signal` and `computed` keep their names. `SignalWatcher(LitElement)` becomes a registration call, `LitSignals.defineElement`, because F# can't apply a mixin to a base class. - [Lit docs: Signals](https://lit.dev/docs/data/signals/) - [Package: Firelight.Signals](https://roboz0r.github.io/Firelight/packages/signals/) ## Watching signals A component registered with `LitSignals.defineElement` re-renders when a signal it read in `render` changes, as a `SignalWatcher` class does. **Lit** ```ts import { LitElement, html } from 'lit'; import { SignalWatcher, computed, signal, } from '@lit-labs/signals'; const count = signal(0); const doubled = computed(() => count.get() * 2); export class CountButton extends SignalWatcher(LitElement) { render() { const add = () => count.set(count.get() + 1); return html` `; } } customElements.define('count-button', CountButton); export class CountDouble extends SignalWatcher(LitElement) { render() { return html`

    Doubled: ${doubled.get()}

    `; } } customElements.define('count-double', CountDouble); ``` **Firelight** ```fsharp open Fable.Core open Firelight open Firelight.Signals open type Firelight.Lit let count = LitSignals.signal 0 let doubled = LitSignals.computed (fun () -> count.get () * 2) [] type CountButton() = inherit LitElement() override _.render() = let add _ = count.set (count.get () + 1) html $"" [] type CountDouble() = inherit LitElement() override _.render() = html $"

    Doubled: {doubled.get ()}

    " LitSignals.defineElement "count-button" LitSignals.defineElement "count-double" ``` The two elements share no parent, property or event, only the signals. Options go in `SignalOptions`: `signal(0, { equals: (a, b) => ... })` is `signal (0, SignalOptions(equals = SignalEquals(fun a b -> ...)))`. ## Updating only what changed The `html` from `@lit-labs/signals` watches each signal placed in a hole, so a change updates that part of the template without running `render` again. In F# it's `LitSignals.html`, which `open type LitSignals` brings into scope as `html`. **Lit** ```ts import { LitElement } from 'lit'; import { SignalWatcher, html, signal, } from '@lit-labs/signals'; const clicks = signal(0); export class ClickPanel extends SignalWatcher(LitElement) { render() { const add = () => clicks.set(clicks.get() + 1); return html`

    Clicks: ${clicks}

    `; } } customElements.define('click-panel', ClickPanel); ``` **Firelight** ```fsharp open Fable.Core open Firelight open Firelight.Signals open type LitSignals let clicks = signal 0 [] type ClickPanel() = inherit LitElement() override _.render() = let add _ = clicks.set (clicks.get () + 1) html $"""

    Clicks: {clicks}

    """ defineElement "click-panel" ``` With Lit's own `html`, `watch` does the same for one hole: `{watch clicks}`, as `${watch(clicks)}`. ## Effects `updateEffect` runs a function after the element's next update, and again whenever a signal it read changes. It's a method of a `SignalWatcher` element in TypeScript; in F# it takes the element as its first argument. **Lit** ```ts import { LitElement, html } from 'lit'; import { SignalWatcher, signal } from '@lit-labs/signals'; const unread = signal(3); export class TitleBadge extends SignalWatcher(LitElement) { connectedCallback() { super.connectedCallback(); this.updateEffect(() => { document.title = `(${unread.get()}) Inbox`; }); } render() { const read = () => unread.set(0); return html``; } } customElements.define('title-badge', TitleBadge); ``` **Firelight** ```fsharp open Fable.Core open Browser open Firelight open Firelight.Signals open type Firelight.Lit let unread = LitSignals.signal 3 [] type TitleBadge() = inherit LitElement() override this.connectedCallback() = base.connectedCallback () LitSignals.updateEffect (this, fun () -> document.title <- $"({unread.get ()}) Inbox") |> ignore override _.render() = let read _ = unread.set 0 html $"" LitSignals.defineElement "title-badge" ``` `updateEffect` returns a function that disposes the effect. Lit disposes it when the element disconnects, so the F# side ignores it, as the TypeScript does. ## No direct equivalent - **`SignalWatcher(...)`.** F# can't apply a mixin, so `LitSignals.defineElement` applies it when it registers the class. The class itself isn't a `SignalWatcher`, so a subclass of it registered with Firelight's plain `defineElement` doesn't watch signals. - **`withWatch`.** Not bound. `LitSignals.html` and `LitSignals.svg` cover Lit's own tags. - **`Signal.subtle`.** The polyfill's low-level API, such as `Signal.subtle.Watcher` for your own effects, isn't bound. The [Firelight.Signals](https://roboz0r.github.io/Firelight/packages/signals/) page has a live example. `@lit-labs/signals` is a Lit Labs package and still experimental. # Cookbook Source: https://roboz0r.github.io/Firelight/cookbook/ Short recipes for common tasks, each with a live demo and its code. A recipe solves one problem and links to the guides for the ideas behind it. ## Forms and input - [Form validation](https://roboz0r.github.io/Firelight/cookbook/form-validation/): check fields in F# on submit and show each error beside its field. - [A custom form control](https://roboz0r.github.io/Firelight/cookbook/form-control/): a switch component that a page's `
    ` submits like a checkbox. - [Search as you type](https://roboz0r.github.io/Firelight/cookbook/debounced-search/): search after a pause in typing, and drop requests for old text. ## Data - [Fetch JSON](https://roboz0r.github.io/Firelight/cookbook/fetch-json/): load JSON with `fetch`, with loading, error and retry states. - [Infinite scroll](https://roboz0r.github.io/Firelight/cookbook/load-more/): load the next page when the end of a list scrolls into view. - [Remember state across visits](https://roboz0r.github.io/Firelight/cookbook/persist-state/): save F# values to `localStorage` and restore them on the next visit. ## Layout and components - [Dark and light themes](https://roboz0r.github.io/Firelight/cookbook/theme-toggle/): system, light and dark themes from custom properties, remembered across visits. - [A card with slots](https://roboz0r.github.io/Firelight/cookbook/slots/): a card that takes its heading, body and footer from the page. - [A modal dialog](https://roboz0r.github.io/Firelight/cookbook/dialog/): ask a question with ``, and read the answer when it closes. - [Tabs](https://roboz0r.github.io/Firelight/cookbook/tabs/): tabs with the ARIA roles, arrow keys, and panels that keep their state. ## Behaviour - [Toast notifications](https://roboz0r.github.io/Firelight/cookbook/toasts/): short messages that any code can raise, shown in one place. - [Keyboard shortcuts](https://roboz0r.github.io/Firelight/cookbook/keyboard-shortcuts/): page-wide keys that skip fields, in a controller any component can use. - [Drag to reorder](https://roboz0r.github.io/Firelight/cookbook/drag-reorder/): reorder a list by dragging, or with buttons from the keyboard. - [Animating list changes](https://roboz0r.github.io/Firelight/cookbook/animate-list/): fade new items in, and slide the rest into place. ## Testing - [Testing components](https://roboz0r.github.io/Firelight/cookbook/testing/): test the logic in plain F#, and the component in a browser with Playwright. # Form validation Source: https://roboz0r.github.io/Firelight/cookbook/form-validation/ Build a sign-up form that checks its fields when it's submitted, shows each error beside its field, and moves focus to the first field that needs fixing. - [MDN: Client-side form validation](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation) Press Sign up with both fields empty. Then type a name: its error clears as you type. ```fsharp module Snippets.SignupForm open System.Text.RegularExpressions open Fable.Core open Browser.Types open Firelight open type Firelight.Lit type Signup = { Name: string; Email: string } /// Each field that has a problem, with its message, in the form's order. let validate (form: Signup) = [ if form.Name.Trim() = "" then "name", "Enter your name." if not (Regex.IsMatch(form.Email.Trim(), @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) then "email", "Enter an email address, such as ada@example.com." ] let private field (id: string) (label: string) (kind: string) (value: string) (error: string option) setValue = let invalid = error |> Option.map (fun _ -> "true") let describedBy = error |> Option.map (fun _ -> id + "-error") let message = match error with | Some text -> html $"""

    {text}

    """ | None -> nothing html $""" {message}""" [] type SignupForm() = inherit LitElement() static member properties = PropertyDeclarations.create [ "form", PropertyDeclaration(state = true) "tried", PropertyDeclaration(state = true) "thanks", PropertyDeclaration(state = true) ] static member styles = css $$""" form { display: grid; gap: 0.35rem; justify-items: start; } input { font: inherit; padding: 0.3rem 0.5rem; width: 16rem; max-width: 100%; } input[aria-invalid] { border: 2px solid var(--accent); } .error { margin: 0; color: var(--accent); } button { font: inherit; padding: 0.3rem 0.9rem; margin-top: 0.5rem; } p[role="status"] { margin: 0.5rem 0 0; } """ member val form = { Name = ""; Email = "" } with get, set /// Whether the user has pressed Sign up: errors show from then on, and clear as they're fixed. member val tried = false with get, set member val thanks: string option = None with get, set member this.Submit(e: SubmitEvent) = e.preventDefault () this.tried <- true match validate this.form with | [] -> // Send this.form to your server here. this.thanks <- Some $"Thanks, {this.form.Name.Trim()}. Check your inbox." this.form <- { Name = ""; Email = "" } this.tried <- false | (firstId, _) :: _ -> this.thanks <- None promise { let! _ = this.updateComplete this.query ("#" + firstId) |> Option.iter _.focus() } |> Promise.start override this.render() = let errors = if this.tried then Map(validate this.form) else Map.empty html $""" {field "name" "Name" "name" this.form.Name (errors.TryFind "name") (fun v -> this.form <- { this.form with Name = v })} {field "email" "Email" "email" this.form.Email (errors.TryFind "email") (fun v -> this.form <- { this.form with Email = v })}

    {match this.thanks with | Some text -> html $"{text}" | None -> nothing}

    """ defineElement "my-signup-form" ``` ```html ``` ## How it works - `validate` is plain F#: a record in, a list of field ids and messages out. It doesn't touch the DOM, so a unit test can call it, and an F# server can run the same checks. - `Ev.submit` types the handler's event, and `e.preventDefault ()` stops the browser's own submission, which would reload the page. - `novalidate` turns off the browser's validation bubbles, so every message comes from `validate`. `type="email"` and `autocomplete` still do their other jobs: the right keyboard on a phone, and autofill. - Errors appear only after the first submit, so nobody is told off for a field they haven't reached yet. From then on, `render` checks the form again on every keystroke, so each error clears as soon as it's fixed. - `aria-describedby` points each field at its message, so a screen reader reads the message with the field, and `aria-invalid` marks the field as wrong. `ifDefined` leaves both attributes out while the field is fine. - After a failed submit, the handler waits for `updateComplete`, then focuses the first field with an error. Before that render, the error messages aren't on the page yet. - The thanks message goes in a `role="status"` paragraph that is always there, so screen readers announce it when its text changes. Its hole holds `nothing` until then, which keeps the [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/) HTML and the first render in the browser the same. ## Keep the form and its fields together A `
    ` only submits the controls in its own DOM tree. A form in the page doesn't see inputs inside a component's shadow root, and a form inside a component doesn't see inputs in the page. Keep the form and its fields in one template, as this component does. To build a control of your own that a page's form submits, see [A custom form control](https://roboz0r.github.io/Firelight/cookbook/form-control/). ## Related - [Templates: Typed event handlers](https://roboz0r.github.io/Firelight/guides/templates/#typed-event-handlers) for `Ev.submit` and `Ev.value`, and [Directives](https://roboz0r.github.io/Firelight/guides/templates/#directives) for `ifDefined`. - [Lifecycle: Reach the DOM after it renders](https://roboz0r.github.io/Firelight/guides/lifecycle/#reach-the-dom-after-it-renders) for `updateComplete`. # A custom form control Source: https://roboz0r.github.io/Firelight/cookbook/form-control/ Build an on/off switch that a `` in the page submits like a checkbox, and that a `