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 |}
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 } )) []
]
]
]
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