davedawkins / Sutil

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

Web components #36

Closed AngelMunoz closed 2 years ago

AngelMunoz commented 3 years ago

As we've been discussing in #32 I took a step into a rough implementation (at least on the JS side) of a class factory that ties some of the Sutil semantics with the class and it's declaration as a custom element Please take a look at the two main files here

export function customElFactory(defaultValues, viewFn) {
    return class extends HTMLElement {
        // allow property names to be observed attributes
        static get observedAttributes() { return Object.keys(defaultValues); }
        constructor() {
            super();
            // create a shadowRoot and make it the context
            this.attachShadow({ mode: 'open' });
            const ctx = makeContext(this.shadowRoot);
            // generate a SutilNode build and asDomElement already do this
            this.__sutilNode = asDomElement(build(viewFn(defaultValues), ctx), ctx);
            // implement getter/setters for properties 
            for (const key of Object.keys(defaultValues)) {
                Object.defineProperty(this, key, {
                    configurable: false,
                    enumerable: true,
                    get: () => this.__getStorePropValue(key),
                    // use the attribute changedCallback from the observedAttributes
                    set: value => this.attributeChangedCallback(key, this.__getStorePropValue(key), value)
                });
            }
        }
        attributeChangedCallback(name, oldVal, newVal) {
            this.__setStorePropValue(name, newVal);
        }
        disconnectedCallback() {
            iterate(item => item?.Dispose(), this.shadowRoot?.firstChild?.__sutil_disposables);
            iterate(item => item?._dispose(), this.shadowRoot?.firstChild?.__sutil_groups);
        }
        // utility methods to get/set values from the store
        get __sutilFistElStore() {
            return head(this.shadowRoot?.firstChild?.__sutil_disposables || empty());
        }
        __getStorePropValue(key) {
            const store = this.__sutilFistElStore;
            return store?.Value?.[key];
        }
        __setStorePropValue(key, value) {
            const store = this.__sutilFistElStore;
            if (!key || !store) return;
            store.Update(store => ({ ...store, [key]: value }));
        }
    };
}
export function defineCustomElement(name, props, viewFn) {
    customElements.define(name, customElFactory(props, viewFn));
}

Above we're Simply exporting two functions that will help us define web components, I had to make this a JS file because (1st familiarity, 2nd I'm not sure we can return anonymous classes in F#) basically when we attach a shadow root (not required for custom elements but helpful for style encapsulation) we can use it as the context for the SutilElement the build and asDomElement functions will internally append the view function result to the shadow DOM (since it is the context) at this point it already works but it is unable to respond to external manipulation (e.g. attribute changes, property changes), since that is a common case for WebComponents/CustomElements I just added getters and setters via Object.defineProperty which read/write values from the elemen'ts store, in addition the static get observedAttributes() { return Object.keys(defaultValues); } also allows us to observe HTML Attributes and since we're using the same attribute changed callback invoked when attributes change we're able to do the following

const sutilCustomEl = document.querySelector("my-element")
// does the update via the element's store
sutilCustomEl.age = 10;

or update the attributes in the HTML to update the values, please note that we're not reflecting attributes hence why the attributes don't change when the store does so.

sutil-wc-2

module Main

open Sutil
open Sutil.DOM
open Fable.Core
open Fable.Core.DynamicExtensions
open Fable.Core.JsInterop
open Browser.Types
open Browser.Dom
open Sutil.Attr

importSideEffects "./styles.css"

let defineCustomElement (name: string, defaults: obj, el: obj) =
  importMember "./web-component.js"

type Stuff = { name: string; age: int }

let view (props: Stuff) =
  let store = Store.make props
  let age = store .> (fun store -> store.age)
  let name = store .> (fun store -> store.name)

  Html.div [
    disposeOnUnmount [ store ]
    bindFragment2 name age
    <| (fun (name, age) -> text $"name: {name} age: {age}")
    Html.button [
      on "click" (fun _ -> Store.modify (fun store -> { store with age = store.age + 1 }) store) []
      text "Update age"
    ]
  ]

defineCustomElement ("my-element", { name = "Frank"; age = 0 }, view)
// same view, different component with different defaults
defineCustomElement ("my-element-2", { name = "Peter"; age = 10 }, view)

In the case of the F# side, we don't really need to do much different things from what we already do

This approach does have some caveats though

  1. The View Function has to have an object as the parameter (Object.keys in the js function), either an anonymoys object or a record
  2. The View Function should have only one store and it should be marked as disposeOnUnmount [ store ] for it to show on the JS side, also the store should be based of the function's first parameter for it to behave as it does in the gif above.

What are we missing?

  1. This just defines a web component, it does not provide an idiomatic API like the one's we're used to (e.g. Html.myElement [ ]`
  2. Styling, I have not dive into styling and how will things work with Shadow DOM
davedawkins commented 2 years ago

This looks amazing.

davedawkins commented 2 years ago

It's not a massive issue with observing external manipulation (in my opinion) - you shouldn't do that to a component in any case, even though the DOM API allows it. As you say, if you think a particular component may want to check for it, then the observation APIs can help. For example, we'd never consider finding the WPF UIElement for a textbox and setting the internal text value, and expect the application to handle / respond to that.

AngelMunoz commented 2 years ago

I can happily say that YES IT WORKS by using the Haunted component function! check the last commit of this PR, it will need a little bit more of work because props don't seem to be automatically changed unless I start tracking attributes, but attributes are always strings so this might be troublesome but that is more about experimenting rather than still trying to figure out if it works

I think we can close this and I can pursue that over the Fable.Haunted repo