diff --git a/sample/Clock.fs b/sample/Clock.fs index 83b440c..c80f1c1 100644 --- a/sample/Clock.fs +++ b/sample/Clock.fs @@ -10,6 +10,14 @@ open Lit.Elmish module Helpers = let hmr = HMR.createToken() + type MouseController = + inherit ReactiveController + abstract x: float + abstract y: float + + [] + let createMouseController (host: LitElement): MouseController = jsNative + type Time = | Hour of int | Minute of int @@ -125,10 +133,12 @@ let select options selected dispatch = """ -let initEl (config: LitConfig<_>) = +let initEl (config: LitConfig<_,_>) = let split (str: string) = str.Split(',') |> Array.map (fun x -> x.Trim()) |> Array.toList + config.controllers <- {| mouse = Controller.Of(createMouseController) |} + config.props <- {| hourColor = Prop.Of("lightgreen", attribute="hour-color") @@ -176,13 +186,14 @@ let initEl (config: LitConfig<_>) = [] let Clock() = - let host, props = LitElement.init initEl - let hourColor = props.hourColor.Value - let colors = props.minuteColors.Value + let host = LitElement.init initEl + let hourColor = host.props.hourColor.Value + let colors = host.props.minuteColors.Value Hook.useHmr(hmr) let model, dispatch = Hook.useElmish(init, update) let time = model.CurrentTime + let mouse = host.controllers.mouse.Value html $"""
-

This is a clock

+

This is a clock: {mouse.x}, {mouse.y}

{select colors model.MinuteHandColor (fun color -> host.renderRoot.querySelector("#message").textContent <- "Color selected" List.tryFindIndex ((=) color) colors |> Option.iter (fun i -> - props.evenColor.Value <- (i + 1) % 2 = 0) + host.props.evenColor.Value <- (i + 1) % 2 = 0) MinuteHandColor color |> dispatch)}
""" diff --git a/sample/controllers.js b/sample/controllers.js new file mode 100644 index 0000000..ea3d835 --- /dev/null +++ b/sample/controllers.js @@ -0,0 +1,48 @@ +import { LitElement } from "lit"; + +export class MouseController { + + constructor(host) { + this.host = host; + host.addController(this); + this.x = 0; + this.y = 0; + } + + _updatePosition = + /** + * + * @param {MouseEvent} event + */ + (event) => { + this.x = event.x; + this.y = event.y; + this.host.requestUpdate(); + }; + + hostConnected() { + window.addEventListener('mousemove', this._updatePosition); + } + + hostDisconnected() { + window.removeEventListener('mousemove', this._updatePosition); + } + +} + + + +class MyControlledElement extends LitElement { + + constructor() { + super(); + this.mouseCtrl = new MouseController(this); + } + + render() { + return `Cursor position: ${this.mouseCtrl.x} - ${this.mouseCtrl.y}`; + } + +} + +customElements.define("my-controlled-element", MyControlledElement); \ No newline at end of file diff --git a/src/Lit/Lit.fs b/src/Lit/Lit.fs index ed43e22..a3a1a72 100644 --- a/src/Lit/Lit.fs +++ b/src/Lit/Lit.fs @@ -3,6 +3,7 @@ namespace Lit open System open Browser.Types open Fable.Core +open Lit module Types = type RefValue<'T> = @@ -44,6 +45,78 @@ type TemplateResult = type CSSResult = abstract cssText: string abstract styleSheet: CSSStyleSheet + +// https://lit.dev/docs/api/controllers/#ReactiveController +// make an empty interface, all of the +// reactive controller methods are optional anyways +// and make each method implement the base +/// Common interface for Reactive Controller Methods +type ReactiveControllerBase = interface end + +type ReactiveHostConnected = + inherit ReactiveControllerBase + + /// + /// Called when the host is connected to the component tree. For custom + /// element hosts, this corresponds to the `connectedCallback()` lifecycle, + /// which is only called when the component is connected to the document. + /// + abstract hostConnected: unit -> unit + +type ReactiveHostDisconnected = + inherit ReactiveControllerBase + /// + /// Called when the host is disconnected from the component tree. For custom + /// element hosts, this corresponds to the `disconnectedCallback()` lifecycle, + /// which is called the host or an ancestor component is disconnected from the + /// document. + /// + abstract hostDisconnected: unit -> unit + +type ReactiveHostUpdate = + inherit ReactiveControllerBase + + /// + /// Called during the client-side host update, just before the host calls + /// its own update. + /// + /// Code in `update()` can depend on the DOM as it is not called in + /// server-side rendering. + /// + abstract hostUpdate: unit -> unit + +type ReactiveHostUpdated = + inherit ReactiveControllerBase + + /// + /// Called after a host update, just before the host calls firstUpdated and + /// updated. It is not called in server-side rendering. + /// + abstract hostUpdated: unit -> unit + +/// +/// A Reactive Controller is an object that enables sub-component code +/// organization and reuse by aggregating the state, behavior, and lifecycle +/// hooks related to a single feature. +/// +/// Controllers are added to a host component, or other object that implements +/// the `ReactiveControllerHost` interface, via the `addController()` method. +/// They can hook their host components's lifecycle by implementing one or more +/// of the lifecycle callbacks, or initiate an update of the host component by +/// calling `requestUpdate()` on the host. +/// +type ReactiveController = + inherit ReactiveHostConnected + inherit ReactiveHostDisconnected + inherit ReactiveHostUpdate + inherit ReactiveHostUpdated + +// https://lit.dev/docs/api/controllers/#ReactiveControllerHost +type ReactiveControllerHost = + abstract updateComplete: JS.Promise + abstract addController: ReactiveControllerBase -> unit + abstract removeController: ReactiveControllerBase -> unit + abstract requestUpdate: unit -> unit type LitBindings = /// diff --git a/src/Lit/LitElement.fs b/src/Lit/LitElement.fs index e0bbc3d..dc04f7e 100644 --- a/src/Lit/LitElement.fs +++ b/src/Lit/LitElement.fs @@ -6,6 +6,7 @@ open Fable.Core.JsInterop open Browser open Browser.Types open HMRTypes +open Lit // LitElement should inherit HTMLElement but HTMLElement // is still implemented as interface in Fable.Browser @@ -20,6 +21,18 @@ type LitElement() = member _.requestUpdate(): unit = jsNative /// Returns a promise that will resolve when the element has finished updating. member _.updateComplete: JS.Promise = jsNative + member _.addController(controller: ReactiveControllerBase): unit = jsNative + member _.removeController(controller: ReactiveControllerBase): unit = jsNative + +// Compiler trick: we use a different generic type, but they both +// refer to the same imported type + +[] +type LitElement<'Props, 'Ctrls>() = + inherit LitElement() + [] + member _.props: 'Props = jsNative + member _.controllers: 'Ctrls = jsNative module private LitElementUtil = module Types = @@ -128,8 +141,22 @@ and Prop<'T> internal (defaultValue: 'T, options: obj) = [] member _.Value with get() = defaultValue and set(_: 'T) = () +type Controller internal (init: LitElement -> ReactiveControllerBase) = + let mutable value = Unchecked.defaultof + member internal _.Init(host) = value <- init host + member _.Value = value + + /// Creates a controller out of an initialization function + static member Of<'T when 'T :> ReactiveControllerBase>(init: LitElement -> 'T) = + Controller<'T>(init) + +and Controller<'T when 'T :> ReactiveControllerBase> (init) = + inherit Controller(fun host -> upcast init host) + member _.Value = base.Value :?> 'T + /// Configuration values for the LitElement instances -type LitConfig<'Props> = +type LitConfig<'Props, 'Ctrls> = + abstract controllers: 'Ctrls with get, set /// /// An object containing the reactive properties definitions for the web components to react to changes /// @@ -151,23 +178,25 @@ type LitConfig<'Props> = /// Whether the element should render to shadow or light DOM (defaults to true). abstract useShadowDom: bool with get, set -type ILitElementInit<'Props> = - abstract init: initFn: (LitConfig<'Props> -> JS.Promise) -> LitElement * 'Props +type ILitElementInit<'Props, 'Ctrls> = + abstract init: initFn: (LitConfig<'Props, 'Ctrls> -> JS.Promise) -> LitElement<'Props, 'Ctrls> -type LitElementInit<'Props>() = +type LitElementInit<'Props, 'Ctrls>() = let mutable _initPromise: JS.Promise = null let mutable _useShadowDom = true + let mutable _ctrls = Unchecked.defaultof<'Ctrls> let mutable _props = Unchecked.defaultof<'Props> let mutable _styles = Unchecked.defaultof member _.InitPromise = _initPromise - interface LitConfig<'Props> with + interface LitConfig<'Props, 'Ctrls> with + member _.controllers with get() = _ctrls and set v = _ctrls <- v member _.props with get() = _props and set v = _props <- v member _.styles with get() = _styles and set v = _styles <- v member _.useShadowDom with get() = _useShadowDom and set v = _useShadowDom <- v - interface ILitElementInit<'Props> with + interface ILitElementInit<'Props, 'Ctrls> with member this.init initFn = _initPromise <- initFn this Unchecked.defaultof<_> @@ -176,13 +205,13 @@ type LitElementInit<'Props>() = member _.hooks = failInit() [] -type LitHookElement<'Props>(initProps: obj -> unit) = - inherit LitElement() +type LitHookElement<'Props, 'Ctrls>(init: obj -> unit) = + inherit LitElement<'Props, 'Ctrls>() let _hooks = HookContext(jsThis) #if DEBUG let mutable _hmrSub: IDisposable option = None #endif - do initProps(jsThis) + do init(jsThis) abstract renderFn: JS.Function with get, set abstract name: string @@ -218,8 +247,8 @@ type LitHookElement<'Props>(initProps: obj -> unit) = |> Some #endif - interface ILitElementInit<'Props> with - member this.init(_) = this :> LitElement, box this :?> 'Props + interface ILitElementInit<'Props, 'Ctrls> with + member this.init(_) = this :> LitElement<'Props, 'Ctrls> interface IHookProvider with member _.hooks = _hooks @@ -245,7 +274,7 @@ type LitElementAttribute(name: string) = config.InitPromise |> Promise.iter (fun _ -> - let config = config :> LitConfig + let config = config :> LitConfig let styles = if isNotNull config.styles then List.toArray config.styles |> Some @@ -278,14 +307,28 @@ type LitElementAttribute(name: string) = else None, fun _ -> () + let initCtrls = + if isNotNull config.controllers then + fun (host: LitElement) -> + JS.Constructors.Object.values(config.controllers) + |> Seq.iter (function + | :? Controller as ctrl -> ctrl.Init(host) + | _ -> ()) + host?controllers <- config.controllers + else fun _ -> () + + let init host = + initProps host + initCtrls host + let classExpr = - let baseClass = jsConstructor> + let baseClass = jsConstructor> #if !DEBUG - emitJsExpr (baseClass, renderFn, initProps) HookUtil.RENDER_FN_CLASS_EXPR + emitJsExpr (baseClass, renderFn, init) HookUtil.RENDER_FN_CLASS_EXPR #else let renderRef = LitBindings.createRef() renderRef.value <- renderFn - emitJsExpr (baseClass, renderRef, mi.Name, initProps) HookUtil.HMR_CLASS_EXPR + emitJsExpr (baseClass, renderRef, mi.Name, init) HookUtil.HMR_CLASS_EXPR #endif propsOptions |> Option.iter (fun props -> defineGetter(classExpr, "properties", fun () -> props)) @@ -370,16 +413,16 @@ module LitElementExtensions = /// Initializes the LitElement instance and registers the element in the custom elements registry /// static member inline init(): LitElement = - jsThis>.init(fun _ -> Promise.lift ()) |> fst + upcast jsThis>.init(fun _ -> Promise.lift ()) /// /// Initializes the LitElement instance, reactive properties and registers the element in the custom elements registry /// - static member inline init(initFn: LitConfig<'Props> -> unit): LitElement * 'Props = - jsThis>.init(initFn >> Promise.lift) + static member inline init(initFn: LitConfig<'Props, 'Ctrls> -> unit): LitElement<'Props, 'Ctrls> = + jsThis>.init(initFn >> Promise.lift) /// /// Initializes the LitElement instance, reactive properties and registers the element in the custom elements registry /// - static member inline initAsync(initFn: LitConfig<'Props> -> JS.Promise): LitElement * 'Props = - jsThis>.init(initFn) + static member inline initAsync(initFn: LitConfig<'Props, 'Ctrls> -> JS.Promise): LitElement<'Props, 'Ctrls> = + jsThis>.init(initFn) diff --git a/test/HookTest.fs b/test/HookTest.fs index 8001376..74da8e3 100644 --- a/test/HookTest.fs +++ b/test/HookTest.fs @@ -110,10 +110,10 @@ let DisposableContainer (r: ref) = [] let DisposableW () = - let _, props = LitElement.init(fun cfg -> + let host = LitElement.init(fun cfg -> cfg.props <- {| r = Prop.Of(ref 0, attribute="") |} ) - let r = props.r.Value + let r = host.props.r.Value let value, setValue = Hook.useState 5 Hook.useEffectOnce @@ -132,10 +132,10 @@ let DisposableW () = [] let DisposableContainerW () = - let _, props = LitElement.init(fun cfg -> + let host = LitElement.init(fun cfg -> cfg.props <- {| r = Prop.Of(ref 0, attribute="") |} ) - let r = props.r.Value + let r = host.props.r.Value let disposed, setDisposed = Hook.useState false let body = diff --git a/test/LitElementTest.fs b/test/LitElementTest.fs index aa45215..cc6a64d 100644 --- a/test/LitElementTest.fs +++ b/test/LitElementTest.fs @@ -21,22 +21,22 @@ let MyEl () = [] let AttributeChanges () = - let _, props = + let host = LitElement.init (fun config -> config.props <- {| fName = Prop.Of("default", attribute = "f-name") |}) html $""" -

{props.fName.Value}

+

{host.props.fName.Value}

""" [] let AttributeDoesntChange () = - let _, props = + let host = LitElement.init (fun config -> config.props <- {| fName = Prop.Of("default", attribute = "") |}) html $""" -

{props.fName.Value}

+

{host.props.fName.Value}

""" let reverse (str: string) = @@ -44,7 +44,7 @@ let reverse (str: string) = [] let AttributeReflects () = - let _, props = + let host = LitElement.init (fun config -> config.props <- {| @@ -54,8 +54,8 @@ let AttributeReflects () = html $""" -

{props.fName.Value}

-

{props.revName.Value |> Array.map string |> String.concat "-"}

+

{host.props.fName.Value}

+

{host.props.revName.Value |> Array.map string |> String.concat "-"}

""" []