Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web Component support #59

Closed
uosis opened this issue Aug 21, 2020 · 16 comments
Closed

Web Component support #59

uosis opened this issue Aug 21, 2020 · 16 comments

Comments

@uosis
Copy link

uosis commented Aug 21, 2020

I was wondering if there is any interest in supporting Web Components in Laminar? I am not sure if there is anything that actually needs to change in the core project, perhaps just having an example of a proper way to add and use Web Components. This would hopefully alleviate the complaint that Laminar has no ready to use components.

I got a proof of concept working as follows:

  1. In the host page's head, add:
  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
  <link href="https://fonts.googleapis.com/css?family=Material+Icons&display=block" rel="stylesheet">
  <script type="module" src="https://unpkg.com/@material/mwc-button?module"></script>
  1. Now you have mwc-button html tag that behaves just like any other tag. Use it in Laminar (this is just a random button added in TodoMVC example):
L.htmlTag("mwc-button", void = false)(
        new ReactiveHtmlAttr[String]("label", StringAsIsCodec) <-- itemsVar.signal.map(i => s"${i.count(!_.completed)} items"))

In summary, the process to add a Web Component is:

  1. Import component's javascript
  2. Define Laminar tag / attributes based on component's documentation

What is the best way to do those two things? In particular:

  1. Referencing an npm module from unpkg like this, while functional, is very inefficient - those modules should somehow be bundled with the rest of js. How can this be done?
  2. What is the proper way to define custom tags? I didn't see any docs for this. Also once defined, what's the best way to make them available to the app?

Thanks for making Laminar!

@raquo
Copy link
Owner

raquo commented Aug 21, 2020

Hey, I don't know much about Web Components other than the general idea, but you're right, they're useful, and this is something that should be documented.

I'm up for making the necessary changes and writing a documentation section about this if you can help me figure out how to use this button Web Component.

I've just pushed a web-components branch to the laminar-examples repo, check it out & feel free to push to it. Note that I've added scalajs bundler to support the js dependency, so you need to to run it with ~fastOptJS::webpack now, not just ~fastOptJS.

MwcButton.scala is our wrapper for the @material/mwc-button web component.

  • It shows how to create custom elements – you basically got this already
  • I'm using standard scala.js @JSImport to load the javascript. It works but there might be a way to avoid val _, not sure.
  • I'm still loading CSS via the html file though because if we loaded it asynchronously, only when the component is instantiated, there would be a delay.

WebComponentsPage.scala contains the usage of this component from Laminar:

MwcButton(
  _.label <-- actionVar.signal,
  _.icon := "code"
)

This API is just something I came up with without thinking too much. It works but better solutions are possible depending on preferences / requirements.

This setup works, however I have no idea how to register a click event listener on this button. If you know how to do this in Javascript, I can translate it into Laminar pretty easily unless we run into scala-js/scala-js-dom#191 or scala-js/scala-js-dom#351 (but worst case we can use js.Dynamic).

Also, I'm not sure if there is anything I should be doing to manage the lifecycle of the web component. Do I need to call destroy on it when it's unmounted or something? I don't know, and once again, if I knew what to do in JS I could solve it in Laminar easily. Or even if I could see a simple example of how this web component is used from some other UI library, that might help too.

@uosis
Copy link
Author

uosis commented Aug 21, 2020

Hey, that branch looks and works great, thanks!

The only issue I noticed is that for some reason host page didn't get copied to output dir during build. But it works fine when referenced from src/main/resources/index-fastopt.html.

Here is relevant summary for using web components:

  1. They work identically to built-in elements for the most part. There is no need to manage their lifecycle manually, browser takes care of that (reference). They always have a dash in the tag name.
  2. Attributes and properties - web components can define custom attributes and properties. Their behavior is identical to built-in elements.
  3. Methods - web components can define custom methods. Again, they behave the same as methods on built-in elements. Here is an example progress bar that defines 2 methods.
  4. Event API is the same as built-in elements. Here is an example of registering a click handler on this button component. In addition to responding to standard events, web components can also define custom events, using the same api. This checkbox for example defines a custom change event.
  5. CSS does not propagate inside web components, but components can define custom properties that can be passed in from the host page.
  6. Slots. This is a new concept in web components - it's essentially a template engine that allows placing inner html elements inside component's html. Reference here, but as far as usage is concerned, we just need to support specifying slot="foo" attribute on any element.

As you can see, there is not a lot of new concepts that need to be introduced. I don't think those Scalajs issues would affect this either - they are for defining your own Web Components, not for using them.

Here is what I think we need to do:

  • define custom elements
  • define custom attributes
  • define custom properties
  • define reflected custom properties/attributes - yep we still need to deal with this
  • define custom events
  • define custom methods
  • define custom css properties
  • define slot attribute - this should probably go in the core types? It is a global attribute as per spec.
  • find out if Scalajs has a way to import for side effects only without using dummy var - Importing native js modules for side effects only scala-js/scala-js#4156
  • create a template "component definition" class, similar to MwcButton, that can be used to easily define any component
  • scrape component documentation and generate component library - I am hoping once we have an easy way to define all those bits, I can just scrape Google's documentation and get Material components for Laminar 😎

Let me know if you have any other questions or would like to see more examples. I think I will create a dummy web component that exercises all those features, so we can easily test this.

@raquo
Copy link
Owner

raquo commented Aug 21, 2020

Thanks for all this info!

You're right about the location of html file, I'm not copying it to minimize the build config. I fixed the docs.

Turns out event props work as-is indeed, I've added onClick to the demo. Not sure what I did wrong the first time.

Regarding reflected properties / attributes: Laminar basically treats those as props, unless you have a specific reason to treat such keys as attributes, this should work just as well in custom elements. I've added an id attribute (actually a prop) to my MwcButton facade to demonstrate.

For custom CSS properties, turns out that the way I'm setting CSS props in Laminar, mutating element.style, didn't work, we need to use element.style.setProperty / removeProperty. I pushed a fix for that to Laminar master, so if you want to try the updated web-components branch you need to pull Laminar master and run +publishLocal in laminar sbt shell (this will "publish" Laminar version 0.10.2-SNAPSHOT locally for you, which is now required for the web-components branch in Laminar Examples).

MwcButton doesn't actually have any custom methods so I pretended it has a "doThing" method and showed how you would use it. I'm pretty sure this pattern will work with a different web component that actually has the methods defined.

See web-components branch updates for all this and more: raquo/laminar-examples@e14533a

More tasks:

  • Move slot attr into Scala DOM Types
  • Publish updated Laminar

@raquo
Copy link
Owner

raquo commented Aug 21, 2020

I've added another commit to demonstrate how slotted children can have reactive contents.

There's a memory management caveat here: the lifecycle of the slotted child's subscriptions will match that of the web component root element because Laminar doesn't know what the web component does with those children. So for example if the web component chooses to unmount some of those children, Laminar won't deactivate their subscriptions (until Laminar unmounts the root of the web component).

@uosis
Copy link
Author

uosis commented Aug 22, 2020

This is awesome, thanks! We now have basically all the cases covered.

I am still not completely clear on how to deal with properties vs attributes. We have 3 cases:

  1. Reflected property/attribute - use ReactiveProp
  2. Property only - use ReactiveProp
  3. Attribute only - use ReactiveHtmlAttr

Is this accurate? Is there a way to define property as read-only?

I am also not a huge fan of the underscore syntax to define custom element's attributes, but I also don't have better ideas. Normally builder style would be cleaner I think, but then it would be inconsistent with how built-in elements are defined.

I created a dummy web component here that exercises all the APIs, in case you want to play with it. I will also play with it probably tomorrow to make sure all the cases work as expected.

@raquo
Copy link
Owner

raquo commented Aug 22, 2020 via email

@uosis
Copy link
Author

uosis commented Aug 22, 2020

Yep, some components expose readonly properties. It makes sense to just add them to the trait together with methods. Thanks.

@raquo
Copy link
Owner

raquo commented Aug 22, 2020

I am also not a huge fan of the underscore syntax to define custom element's attributes, but I also don't have better ideas. Normally builder style would be cleaner I think, but then it would be inconsistent with how built-in elements are defined.

I don't really want this template to be part of Laminar itself, it's just an example in the other repo. As you see there isn't much to abstract away, it's only slightly more involved than writing a regular JS facade. If you want to create a different Web Components example with a different approach that would be great, feel free to add it to that branch, laminar-examples is specifically for showcasing a variety of styles and patterns.

Anyway stylistics aside, I've published Laminar v0.10.2 that has the slot reflected attribute and the CSS fix, so it looks like on the library side we have Web Components support covered pretty much. Thanks for your help! I'll write the docs a bit later.

@uosis
Copy link
Author

uosis commented Aug 22, 2020

I played around with my dummy component to exercise all the APIs, and things are working great for the most part. I added a couple new material components to our example to demonstrate usage of actual custom events and methods.

I ran into a few minor issues:

  1. Method invocation is failing here. I am not sure why the element is null there.
  2. I couldn't find a way to refine custom event target type so that casting is not needed here. Is this possible?
  3. To be able to read the value of a property, it looks like we need to define it twice, here and here - is this correct, or is there a way to do it without repeating?

Thanks for all your help with this!

@raquo
Copy link
Owner

raquo commented Aug 22, 2020

Thanks, those are nice additions!


Regarging the failure of .layout() call, the element (.ref) is actually defined, and it does have a setLayout method defined on it, which is implemented by the web component as follows:

layout() {
  this.mdcFoundation.layout()
}

However, at the moment in time when Laminar has mounted the element and onMountCallback fires, this.mdcFoundation is still undefined, even though the web component's element has already been created and all props already passed to it. If you wait 1ms after mounting for example, you can call .layout() without issue. The method returns undefined, but I don't know what it's supposed to return.

Looking at the implementation of the web component, it has a createFoundation method, which if you put a breakpoint on it you'll see is being called asynchronously after a bunch of *update method calls. I don't know why it's done like this, but this seems to be an implementation quirk of this particular web component?

Bottom line, the web component seems to be initialized at the right time, before onMountCallback is called, however, its initialization is async for some reason, whereas Laminar calls onMountCallback synchronously to prevent flashes of incomplete content.


Event target type refinement is typically achieved with Laminar's inContext method, I've updated the code, check it out. Also note how we don't need to call progressVar.set, we just use .mapTo and just put progressVar on the receiving end of the --> arrow.


Regarding value duplication, Laminar's DomApi has methods like setHtmlProperty. We could potentially add the corresponding reading methods like getHtmlProperty, allowing you to say something like sliderEl.getHtmlProperty(value), HOWEVER there's a big caveat that the property in question might not be defined, and I don't think there's a good way to reflect that in types without being annoyed by Option[A], Option[undefined], etc. So I think duplication is the lesser evil here. Typically unidirectional data flow design means that you're either reading or writing a property, rarely both, even if technically all the properties are exposed.

All the prop types we have in Scala DOM Types also duplicate the native JS props, it's just that those native props are defined on the js facades like dom.html.Element in scala-js-dom so they're out of view, but fundamentally the duplication is still there.

@uosis
Copy link
Author

uosis commented Aug 23, 2020

Bottom line, the web component seems to be initialized at the right time, before onMountCallback is called, however, its initialization is async for some reason, whereas Laminar calls onMountCallback synchronously to prevent flashes of incomplete content.

Ah, this explains it. That method is just to update/"redraw" the layout of the slider, so it's supposed to return undefined. Looks like everything is working fine here, and that is just a quirk of that component.

Event target type refinement is typically achieved with Laminar's inContext method, I've updated the code, check it out. Also note how we don't need to call progressVar.set, we just use .mapTo and just put progressVar on the receiving end of the --> arrow.

Looks great! Yeah I was thinking of just using the node reference that we already have, rather than extracting it from event, but didn't know about inContext that makes it easy.

So I think duplication is the lesser evil here. Typically unidirectional data flow design means that you're either reading or writing a property, rarely both, even if technically all the properties are exposed.

I agree. I imagine most of the time these definitions would be generated anyway, so duplication doesn't really matter.


So I think that branch is now in a pretty good shape - we have several components that cover all the Web Component APIs, and also example usage that also covers all APIs. It should be easy to follow this to define any Web Component.

I will update the example if/when Scalajs people respond about imports, but if not, it's not really a big deal to leave it as is.

I will also create material component library using this pattern when I get a chance, in a separate repo.

Thanks for resolving this so quickly!

@raquo
Copy link
Owner

raquo commented Aug 24, 2020

Cool, thanks for your help too, I merged the examples web-components branch into master.

I will also create material component library using this pattern when I get a chance, in a separate repo.

That would be so awesome, people have been asking for this kind of thing forever! It doesn't even need to be comprehensive from the start, I think you'll find contributors for such a project pretty easily if you just start with something.

Cheers

@raquo raquo closed this as completed Aug 24, 2020
@mathieuleclaire
Copy link

Hi,
is there any effort to start a repository with Web Component wrapping so that they can be directly be used in a web ui construction ? Or is there only Button, Slider, ProgressBar wrappers so far ?

Thanks for this amazing work !

@raquo
Copy link
Owner

raquo commented Nov 25, 2020 via email

@mathieuleclaire
Copy link

Excellent, thanks for sharing. I will try to work with this.
Peharp's you could mention it on your front web page and/or rely on it in your laminar example ?

@raquo
Copy link
Owner

raquo commented Nov 25, 2020 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants