davedawkins/Sutil

Expose host to web components

AngelMunoz opened this issue · 3 comments

Many things in web components work with the class that creates the web component for example Fable.ShadowStyles allows setting constructable stylesheets on web components BUT they need to have access to the shadow root of the element

I propose to change the function inside WebComponents.fs for the following one

let registerWebComponent<'T> name (ctor : IStore<'T> -> HTMLElement -> SutilElement) (init : 'T) : unit =

    let wrapper (host:Node) : WebComponentCallbacks<'T> =
        let model = Store.make init
        let result = ctor model host // this change right here
        let disposeElement = DOM.mountOnShadowRoot result host

        let disposeWrapper() =
            model.Dispose()
            disposeElement()

        {   Dispose = disposeWrapper
            GetModel = (fun () -> model |> Store.current)
            SetModel = Store.set model }

    makeWebComponent name wrapper init

then we would be able to do something like the following

module Components.Counter

open Fable.Core
open Fable.Core.JsInterop
open Sutil
open Sutil.DOM
open Sutil.Attr
open Sutil.WebComponents

open ShadowStyles
open ShadowStyles.Operators
open Browser.Types

type private ComponentProps = {| init: int |}

let private styles =
  // this is part of shadow styles
  [ "button" => [ SCss.backgroundColor "#FFCC00" ]
    "div" => [ SCss.color "#FF00FF" ] ]

let private Counter (props: Store<ComponentProps>) (host: HTMLElement) =
  // ShadowStyles can set the constructable stylesheets to the webcomponent
  ShadowStyles.adoptStyleSheet (host, styles)
  let count = props .> (fun p -> p.init)
  let initVal = props |-> (fun props -> props.init)
  let getCount () = props |-> (fun props -> props.init)

  Html.div [
    disposeOnUnmount [ props ]
    Bind.fragment count (fun counter -> Html.text $"{counter}")
    Html.br []
    Html.button [
      onClick
        (fun _ ->
          props
          <~= (fun props -> {| props with init = getCount () + 1 |}))
        []
      Html.text "Increment"
    ]
    Html.button [
      onClick
        (fun _ ->
          props
          <~= (fun props -> {| props with init = getCount () - 1 |}))
        []
      Html.text "Decrement"
    ]
    Html.button [
      onClick
        (fun _ ->
          props
          <~= (fun props -> {| props with init = initVal |}))
        []
      Html.text "Reset"
    ]
  ]

let register () =
  registerWebComponent "su-counter" Counter {| init = 0 |}

which would result in this
image

this is the main reason why I primarily used classes in this PR https://github.com/davedawkins/Sutil/pull/43/files#diff-469049174da5c0501dc98ae73a9c8a7b17aa9c7abead51ee31221916da6142cbR27 but I still kept the function call inside the web component rather than delegating the function call to other places, with Fable is hard to keep track of "this", in this case it can be fixed easily by providing the host as a parameter.

Also another very important aspect is event targets, Web components should dispatch events when they are affected by user input, so events should ideally be dispatched from the web component itself rather than a button or another element inside the web component, for more context check fable-compiler/Fable.Lit#14

we could also expose an init function that gives you the host one time instead to do these kinds of things.

Why not to include ShadowStyles into fable? Constructible Stylesheets are not available in all browsers yet, so this has to be polyfilled in browsers that are not chromium based but they are the most performant and simple means to have shared stylesheets for web components otherwise Sutil would have to find a way to apply styles to Sutil Elements

I did have the host passed into the component constructor (and in fact the JS does do this), but I'm using a wrapper in Sutil/WebComponents.fs. I like to have APIs that support the simplest case, and then offer support for more complex scenarios. It sounds like static members with overloads for RegisterWebComponent will help with this.
Thanks for the example and suggestions!

So we now have

type WebComponent =
    static member Register<'T>(name:string, ctor : IStore<'T> -> Node -> SutilElement, init : 'T )   
    static member Register<'T>(name:string, ctor : IStore<'T> -> SutilElement, init : 'T )

which allows

let Counter (model : IStore<CounterProps>) (host : Node) =
    Html.div [
      // ...
    ]

WebComponent.Register("my-counter",Counter,{ label = ""; value = 0})

However, I've also added adoptStyleSheet : StyleSheet -> SutilElement which means you can do this in the component:

let CounterStyles = [
    rule "div" [
        Css.backgroundColor "#DEEEFF"
    ]
    rule "button" [
        Css.padding (Feliz.length.rem 1)
    ]
]

let Counter (model : IStore<CounterProps>) (host : Node) =
    Html.div [
        adoptStyleSheet CounterStyles

        Bind.el(model |> Store.map (fun m -> m.label),Html.span)
        Bind.el(model |> Store.map (fun m -> m.value),Html.text)

        Html.div [
            Html.button [
                text "+"
                onClick (fun _ -> model |> Store.modify (fun m -> { m with value = m.value + 1 } )) []
            ]
            Html.button [
                text "-"
                onClick (fun _ -> model |> Store.modify (fun m -> { m with value = m.value - 1 } )) []
            ]
        ]
    ]

And you get:
image

This means you don't need the host parameter, Sutil finds it for you, once the component has been 'onConnectedCallback'.

Comments:

  • I've used the techniques from Fable.ShadowStyles to implement adoptStyleSheet, allowing us to use the Sutil styling data types
  • It would be easier to just use the host parameter and use it to adopt the stylesheet right there, rather than make it a child action of the div! I'm not sure of the most intuitive way to do this yet.
  • I'm not sure what's going on with the polyfill - it gives a "sheet is null" error in clearRules - so I may look into more accepted/stable ways of styling shadow DOM than constructed stylesheets for now

Fixed in next release