Wrapping JsPlumb in Web Components
Introduction
Web Components have been around for quite some time now, but it's probably fair to say that they have not reached the widespread adoption that in the early days it seemed they might. I never really started using them for various reasons:
- Incomplete browser support JsPlumb needs to work on as many browsers as possible and does not have the luxury of requiring the latest browsers. It took quite some time for all of the major browsers to bring their web components support up to the spec.
- Relatively poor documentation Nowadays there's a wealth of articles discussing web components, including a great one on MDN, but this was not the case at first.
- Clunky developer interface This is of course a subjective viewpoint, but it's certainly not just me who thought so. One of the fundamental issues for me was trying to figure out how they could be composed as easily as something like React/Angular etc, particularly when the only input to a web component is through its attributes, and they have to be strings.
Recently I've been looking at web components anew: with the upcoming 7.x release of JsPlumb we're keen to explore all opportunities to use up-to-date technologies, and we thought web components deserved a second look. There seems to be a groundswell of people adopting them, and so we're interested in exploring if there's any value for our licensees in us adding some kind of web components support.
Specifically, we're wondering if we can support something like this:
<jsplumb-surface :view="view" :renderParams="renderParams" id="mySurface">
<jsplumb-controls></jsplumb-controls>
<jsplumb-miniview></jsplumb-miniview>
</jsplumb-surface>
Our use case is for some components that are lightweight wrappers around various parts of the JsPlumb UI, and which, once painted, won't need to be repainted. The internal contents of the components will be repainted, of course, but the web component itself is static.
In the HTML above we see three attributes on the jsplumb-surface element:
- :view These are the view options - the mappings from object types to their visual representation and behaviour
- :renderParams These are the options for the Surface
- id A unique ID for the the Surface
The HTML above mimics what you might use with one of our library integrations. But in vanilla JsPlumb to create this UI you need to mount these things programmatically. First you need some HTML:
<div id="mySurface">
<div id="controls"></div>
<div id="miniview"></div>
</div>
Then you need some JS to mount everything:
const container = document.getElementById("container")
const controlsContainer = document.getElementById("controls")
const miniviewContainer = document.getElementById("miniview")
const renderParams = { ... }
const view = { ... }
const surface = someToolkit.render(container, renderParams)
const controls = new ControlsComponent(controlsContainer, surface)
surface.addPlugin({
type:MiniviewPlugin.type,
options:{
container:miniviewContainer
}
})
The problem: attributes are strings
It seems that the fundamental disconnect between the HTML I want to be able to use and reality is that in web components all attributes are strings. I can't just plug in some view options or render params to a component - unless I were to serialize them first, and then deserialize them inside the component, which is very unappealing.
Imagine I wanted to write a web component that formats some Date that I pass in. I'd kind of like to do something like this:
customElements.define("date-label", class extends HTMLElement {
async connectedCallback() {
const date = this.getAttribute("date")
const formatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' })
const formattedDate = formatter.format(date)
this.innerHTML = formattedDate
}
})
...except this will not work, because the date attribute is a string. Should I format the date before passing it into this component, then? What would be the point of the component if I did that?
Contrast this with JSX - here I have some DateLabel component that takes a Date as argument
export function DateLabel({value}) {
const formatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' })
const formattedDate = formatter.format(value)
return <h1>{formattedDate}</h1>
}
I can use this component elsewhere and pass the date in:
export function MyApp() {
const info = {
label:"Foo Label",
aNumber:25,
date:new Date()
}
return <div>
<DateLabel value={info.date}/>
</div>
}
On the surface (excuse the pun) this seems to be a deal breaker. How can web components be of any use with this constraint? But then I realised the fundamental mistake I was making: I was expecting to be able to use web components without any kind of wrapper at all, but that's not how I write React apps, or Angular/Vue/Svelte etc - there is always some object providing a context to use as a base.
It's about Context
So, then, in order to be able to pass objects around to my web components, I need a context, and I need an entry point.
Render function
I'm going to define this wcRender function, which behaves like the createRoot method in React:
export function wcRender(container, template, data) {
container.dataset.wc = "true"
container.__data = data || {}
const document = new DOMParser().parseFromString(template, "text/html")
while(document.body.hasChildNodes()) {
container.appendChild(document.body.firstChild)
}
}
We pass in to this method:
- container The DOM element we will render into
- template A string containing the markup we want to render (which will have web component tags in it)
- data Data to use when rendering. In this example, this will include some Date my tag can render
This function does the following:
- Sets a
wcproperty on the container'sdataset. We do this to mark the element as a web component container, and when you set a property on a DOM element'sdataset, a matchingdata-***attribute is set on the element - in this case,data-wc="true". We'll revisit this in ourBaseComponentbelow. - Writes the provided
data(or an empty object if this is null) onto the container as the__dataproperty. Our components will retrieve their data from here. - Parses the given template via a
DOMParser - Copies all of the parsed nodes in as children of the container
Date label component
Our date label component will need to look for this context component and get its value from it.
customElements.define("date-label", class extends BaseComponent {
async connectedCallback() {
const _context = this.closest("[data-wc]")
const date = _context.__data["date"]
const formatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' })
const formattedDate = formatter.format(date)
this.innerHTML = formattedDate
}
})
We've now got the bits we need to render our date label component and supply it with a Date.
Template
We'll define a template to render:
<date-label></date-label>
Rendering
And we'll render that template like this:
wcRender(someContainer, template, {date:new Date()})
Output
Spectacular!
Extracting the dryness
As it stands the above setup has allowed me to render some arbitrary date in a tag, but what if I wanted to write another component that uses the context? I'd have to duplicate the code that locates the context and extracts a value from it:
customElements.define("text-label", class extends BaseComponent {
async connectedCallback() {
const _context = this.closest("[data-wc]")
this.innerHTML = _context.__data["text"]
}
})
Also, our date-label has hardcoded date as the value it wants to extract from the context. Time for a little refactoring.
BaseComponent
This component will provide the common functionality that we'll need - discovery of, and access to, the context, as well as functionality to map attribute names to model values.
class BaseComponent extends HTMLElement {
_boundAtts = {}
constructor() {
super()
this._context = this.closest("[data-wc]")
for(let i = 0; i < this.attributes.length; i++) {
const att = this.attributes.item(i)
if (att.name.startsWith(":")) {
this._boundAtts[att.name.substring(1)] = att.value
}
}
}
getValue(modelKey) {
const mappedKey = this._boundAtts[modelKey] || modelKey
return this._context.__data[mappedKey] || this.getAttribute(modelKey)
}
}
Let's look at what's going on here:
-
We have a
_boundAttsrecord, which we populate in the constructor, by iterating through the attributes defined on the element, looking for attributes that are prefixed with a:, such as:text, or:date, etc. -
Our constructor locates the parent element that is acting as our context and stores it on the component.
-
We declare a
getValue(key)method, which is responsible either for extracting values from the context or from the element's attributes. This method first checks to see if the requested key has been mapped to some model key, or whether to use it directly. We'll show an example of what this means below.
A better date label
Now we can rewrite our original date label copmonent, to extend BaseComponent and not have to lookup the context ourselves.
customElements.define("date-label", class extends BaseComponent {
async connectedCallback() {
const date = this.getValue("value")
const formatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'short' })
const formattedDate = formatter.format(date)
this.innerHTML = formattedDate
}
})
We also use the getValue(..) method declared by BaseComponent to extract values from the context.
A better text label
We can now write our text label component, using BaseComponent:
customElements.define("text-label", class extends BaseComponent {
async connectedCallback() {
this.innerHTML = this.getValue("label")
}
})
Template
We'll render these better components with this template:
<h6>Now</h6>
<date-label :value="now"></date-label>
<h6>Tomorrow</h6>
<date-label :value="tomorrow"></date-label>
<h6>Default label</h6>
<text-label label="This is the default label"></text-label>
<h6>Named label</h6>
<text-label :label="namedLabel"></text-label>
Notice how each of our date-label elements has a :value attribute. The value of the attribute is the name of the data element to render - now for one, and tomorrow for the other.
We also render two text-label elements. The first of these has a label attribute, but it is not prefixed with a :, meaning we've provided the value we want to use. The second text-label element declares :label="namedLabel", meaning "extract the value corresponding to the model element namedLabel"
Rendering
This will be our render call now:
const now = new Date()
const tomorrow = new Date(now.getTime() + (1000 * 60 * 60 * 24))
wcRender(someContainer, template, {
now,
tomorrow,
namedLabel:"This is the value of the named label"
})
Output
Our output now looks like this:
We've extracted values from the model for the date-label components, used a hardcoded attribute for one text-label, and used a model value for the other text-label. Definitely making progress.
Surface component
In about 30 lines of code our wcRender function and BaseComponent together provide a neat little solution to the problem of instantiating web components with data, and this is probably enough for us to use as a basis for what we wanted to investigate from a JsPlumb perspective: it is possible to provision the JsPlumb Toolkit as web components?
Let's define a wrapper around our Surface:
customElements.define("jsplumb-surface", class extends BaseComponent {
async connectedCallback() {
const view = this.getValue("view")
const rp = this.getValue("renderParams")
const id = this.getValue("id")
const data = this.getValue("data")
const tk = newInstance()
this._surface = tk.render(this, Object.assign(rp, {view} ))
tk.load({data})
}
})
And then render it:
const tpl = `<jsplumb-surface :view="view" :renderParams="renderParams" id="mySurface" :data="data"></jsplumb-surface>`
wcRender(someContainer, tpl, {
view:{
nodes:{
default:{
template:`<div class="jtk-wc-demo-node">{{id}}</div>`
}
}
},
renderParams:{
layout:{
type:"Absolute"
},
defaults:{
endpoint:"Blank"
}
},
data:{
nodes:[
{ id:"1", left:10, top:10 },
{ id:"2", left:200, top:200 }
],
edges:[
{ source:"1", target:"2"}
]
}
})
Excellent!
Controls component
How about we add a controls component to this:
customElements.define("jsplumb-controls", class extends BaseComponent {
async connectedCallback() {
const surface = this.closest("jsplumb-surface")._surface
new ControlsComponent(this, surface)
}
})
And update our template to this:
<jsplumb-surface :view="view" :renderParams="renderParams" id="mySurface" :data="data">
<jsplumb-controls></jsplumb-controls>
</jsplumb-surface>
Almost to where I wanted to be - just need to do the miniview.
Miniview component
customElements.define("jsplumb-miniview", class extends BaseComponent {
async connectedCallback() {
const surface = this.closest("jsplumb-surface")._surface
surface.addPlugin({
type:MiniviewPlugin.type,
options:{
container:this
}
})
}
})
Update our template:
<jsplumb-surface :view="view" :renderParams="renderParams" id="mySurface" :data="data">
<jsplumb-controls></jsplumb-controls>
<jsplumb-miniview></jsplumb-miniview>
</jsplumb-surface>
And that's it - a lightweight web components wrapper around JsPlumb. Success!
Where to next?
Updating components
We've achieved what we set out to here - we have web component wrappers around our JsPlumb components. But the developer in me looks at this code and wonders what it would take to use it to support some kind of scenario where the components could update their UI based on changes to the data model. We might make this the topic of a future blog post - reach out if you're interested in seeing that.
Shipping this
Are you a licensee or evaluator of JsPlumb and you'd like to see us ship this? Get in touch at the email shown below and let us know.
Start a free trial
Get in touch!
If you'd like to discuss any of the ideas/concepts in this article we'd love to hear from you - drop us a line at hello@jsplumbtoolkit.com.