davedawkins / Sutil

Lightweight front-end framework for F# / Fable. No dependencies.
https://sutil.dev
MIT License
285 stars 17 forks source link

Expose host to web components #46

Closed AngelMunoz closed 2 years ago

AngelMunoz commented 2 years ago

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 https://github.com/fable-compiler/Fable.Lit/issues/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

davedawkins commented 2 years ago

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!

davedawkins commented 2 years ago

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:

davedawkins commented 2 years ago

Fixed in next release