Yozo

A small library enabling you to write high-quality, robust custom elements quickly and easily.

Yozo leverages custom elements and shadow DOM to achieve simple, encapsulated component-based front-end architectures. On top of allowing you to write single-file components with friendly API's, it provides a few functions to make your life easier. Some of them being used by Yozo itself, some specifically designed to have nice synergy with Yozo.

Not familiar with custom elements and/or shadow DOM? MDN has some great articles on the subjects; I recommend you read up on these two topics before using Yozo. This article covers most of it.

Installation

No installation is required! No configuration nonsense, just a simple

import * as yozo from 'https://deno.land/x/yozo'

and you're good to go! You can also specify Yozo in your importmap, so you can simply import from 'yozo' directly.

Note: you don't have to use deno for this to work!

What do you get?

Now, what does Yozo actually get you? Well, the package itself only exports a handful of things. The big one is register; that allows you to define your custom elements.

register

This function takes one or two arguments. The first is a URL (either a string or URL object), pointing to your custom element's definition. The second is an optional options object. It may contain an as key, with a custom element name as value, that the component will be registered as. This has a higher precedence than the name defined in the custom element's definition! This is mostly useful for renaming third-party components. Your registration will look more-or-less like this:

import { register } from 'https://deno.land/x/yozo'

register('./path/to/custom-dropdown.ce')
register('https://example.com/custom-elements/toggle-switch.ce', {as: 'custom-switch'})

Note that importing the same URL multiple times, even with different options objects, doesn't register an element multiple times. A custom element will be registered only once. If you really need to define multiple custom elements with identical definitions, simply append some query parameters to the file URL.

stateify

Available as a standalone package as well, see stateify.

reversible, until

Available as a standalone package as well, see reversibles.

when

A function from the library provided by reversibles, see when.

define (!)

NOT INTENDED FOR DIRECT USE. This is a function intended to be imported in the custom element definitions. Yozo already imports this into your definitions automatically, so you should never have to use this. Using it may result in errors.

In custom element definitions

The files you register with the register function are single-file custom element definitions. They contain the HTML, the logic and the styles needed for a single custom element. The file extension, while Yozo recommends .ce, really does not matter. A good alternative is just .html, since the custom element definitions are valid HTML, and so syntax highlighting will work out of the box. However, they're not valid HTML documents (just a part of a document), and really, they don't make a whole lot of sense without Yozo, so making a distinction may be desirable. Anyway, your file will look roughly like

<template>
    <!-- your HTML -->
</template>
<script>
    // your JavaScript
</script>
<style>
    /* your CSS */
</style>

<template>

Your template will end up in the shadow DOM of the custom element. This means you can leverage everything that vanilla custom elements provide you - <slot>s, parts, simple classes and ids without having to worry about clashes, etcetera.

The template element itself will pass its attributes as key-value pairs to the shadow root's initialization object (the thing you'd normally pass to attachShadow()). That means you have these two options:

mode

The shadow roots Yozo creates are "open" by default. You can, however, explicitly specify the shadow root to be closed by adding the attribute mode="closed" on the <template> element.

delegates-focus

This is a boolean attribute you can put on the <template> element, marking the element as "delegating focus". As you'd expect, this sets the delegatesFocus property in the initialization object to true. When an element inside the shadow DOM receives focus, it will be delegated to the shadow root's host.

<script>

The script tag is where the magic happens. The code you write here is a JavaScript module, which means you can import things. Yozo provides a few "globals" for you to use - essentially, these are variables that are always available to you in the context of a custom element definition, but they're not actually on the window. They are being imported the same way you could import stuff, but you don't actually have to write the import statement. Also, while you can normally use top-level await expressions in modules, this is not allowed in custom element definitions.

define

You can call define as a function to provide a default custom element name. These may be overwritten using the options in the register function, but if you're writing the components for use in your own project, you will probably want to name them this way. For example:

<script>
    define('custom-dropdown')
</script>

define.attribute

Defining, keeping track of, and creating properties for attributes was a bit of a hassle for vanilla custom elements. Yozo makes this easy. You can register attributes to be "observed attributes" by calling e.g. define.attribute('serial-number'). Then, you can use this[attributes]('serial-number) to listen to changes in the attribute - see the section on [attributes] for more info on this. This is not where it ends though; define.attribute can take a second argument, an options object, that can take a type, an as, and a default key.

The type key allows you to specify the type of data the attribute will take. Yozo will then automatically create an accompanying property. The type should be provided as a function, which will get the attribute's value as a string and will process the value to be returned by the property getter. Most often you'll want to use something like Number or Boolean - the constructors are great for converting strings to their primitive equivalents. The value Boolean is treated a bit differently - this will turn the attribute in a boolean one. That is to say, the value of the attribute depends on its presence or absence rather than its value. Its usage looks something like this:

<script>
    define('shop-product')
    define.attribute('serial-number', {type: Number})
    define.attribute('has-promo', {type: Boolean})
</script>

The above example will allow you to get and set the attributes using the .serialNumber and the .hasPromo property respectively. Note that the property for the attribute is converted to camelCase, since that's the most sensible default.

If that default property name doesn't suit your needs though, don't worry - the as key allows you to rename the property. If, for example, you define.attribute('serial-number', {type: 'number'}), but you want to get and set this attribute with the .serialnr property rather than the .serialNumber property, you can specifiy as: 'serialnr' and Yozo will instead use that to define the property. You may also use an array of properties, so you could have both the .serialNumber as well as the .serialnr properties at the same time for the same serial-number attribute.

The default key lets you specify a value for when the attribute is absent. If you leave this out, the default is null (which is what getAttribute returns for attributes that don't exist). Often, when you use e.g. a string or number-type attribute, you'll want to specify this so that the property always has a value according to the type you defined. For example, define('serial-number', {type: Number, default: 0}).

For more complex getters and setters for attributes, use define.property instead.

define.method

With both the lifecycle callbacks as well as the introduction of private methods, vanilla custom element definitions can become hard to oversee. What are the methods exposed to the outside worlds, and what are the methods intended for internal logic? Yozo makes this a bit clearer. You define your methods explicitly by calling define.method. It takes a string or symbol as first argument (the name of the method), and a function (the method itself) as second argument. Something like so:

<script>
    define('rocket-ship')
    define.method('launch', function(){
        if(this.fuel < 23) throw Error('Not enough fuel!')
        this.startEngines()
    })
</script>

Be mindful to not use arrow functions in these definitions; they're methods, and so they'll probably need the this value (referring to the custom element itself). When using arrow functions, you will not have access to this.

define.property

This function is very similar to define.method, but instead of methods, it allows you to define properties. The first argument it takes is the property name (either a string or a symbol) and the second argument may be either a function, if it is just a getter, or a complete descriptor object a la Object.defineProperty. Like with define.method, make sure to not use arrow functions if you need access to the custom element instance:

<script>
    define('todo-list')
    define.property('length', function(){
        return this.getItems().length
    })
    let allowReadSecrets
    define.property('allowReadSecrets', {
        enumerable: false,
        get(){
            return allowReadSecrets
        },
        set(value){
            if(!this.isAdmin()) return
            allowReadSecrets = value
        }
    })
</script>

define.form

Custom elements may be form-associated, and this function allows you to specify this. Simply call it once, with no arguments, to opt-in to this behavior. Then, you can use this[internals] to e.g. set the underlying value for use in <form> elements. See the section on [internals] for more info.

construct

This is analogous to the constructor for custom elements. With Yozo though, you don't need to call super(), attach your shadow DOM and template, or your CSS; Yozo does all of that for you, so that you can focus on the logic. Here, set up everything that needs to be alive for the entire duration of the component, whether connected to the document or not. For example, loading some data into the component, or setting up some of the internal state for the component.

connect

This is equivalent to the connectedCallback in custom elements. However, Yozo will automatically make the function your provide here a reversible - that means that you can just set up your component using reversible functions such as when, and Yozo will automatically take everything down for you when the element disconnects, so that you don't have to define a disconnect callback at all. For example:

<script>
    define('button-clicker')
    define.method('triggerClick', function(){
        this[elements].button.click()
    })

    connect(() => {
        when(this[elements].button).does('click').then(() => {
            console.log('clicked')
        })
    })
</script>

Yozo will automatically detach the event listener when the element disconnects from the DOM, and set it back up once the element reconnects. Calling triggerClick when the element is detached will therefore do nothing, but when it is connected, it will log clicked to the console.

Note that you should use function(){} over arrow functions if you want the this keyword to be available (and you probably want that) - though, you can use array functions if you want to, as Yozo provides the this value as first argument. This means you can use connect(instance => { console.log(instance) }) as well as connect(function(){ console.log(this) }).

disconnect

Generally, you won't (shouldn't) need this. connect should automatically take everything down. Howver, if you want to, you may use this callback to take some things down manually. Note that you may be better off by writing a generic reversible helper so you can use it in other components as well without the mental overhead of a disconnect callback.

[attributes]

Yozo provides a few key things to you in the form of symbols, so that they are still shielded from the outside. The [attributes] symbol exposes event targets for the attributes you registered. They will fire the change event whenever that attribute changes. For example:

<script>
    define('custom-checkbox')
    define.attribute('checked', {type: 'boolean'})

    construct(function(){
        when(this[attributes]('checked')).changes().then(event => {
            const {oldValue, value} = event.detail
            if(value == false) this.showRequiredMessage()
            else this.allowNextStep()
        })
    })
</script>

As you can see in the example above, the detail key in the event object will contain some data about the attribute that changed; specifically, it tells you the old value (.oldValue), the new, current value (.value) and the attribute name (.attribute).

You might also want to listen to multiple attributes at the same time. For this, you may simply pass more attributes to the this[attributes] function. Yozo also allows you to use a wildcard, i.e. this[attributes]('*'), to listen to all attribute changes. The event handlers are guaranteed to be called in the same order you attached them.

Note that you need to register the attributes using define.attributes in order to listen to their changes. If you want to listen to arbitrary attribute changes, even unregistered ones, use a mutation observer. This can be easily achieved with when as well:

    define('custom-div')

    construct(function(){
        when(this).observes('mutation', {attributes: true}).then(() => {
            console.log('attribute changed!')
        })
    })

[elements]

You've got a template, and you probably want access to the elements in it. This symbol lets you do so, by exposing the querySelector on the shadow root to you without you having to write it all out. You pass the selector to the this[elements] function directly, e.g. this[elements]('button.btn > span'). You also get a querySelectorAll variant, of course: this[elements].all('button.btn > span'). This returns an array of elements rather than a NodeList, allowing you to immediately use methods like .filter or .map on the result without having to use the spread operator on the result.

[internals]

This exposes the element internals, including the shadowRoot (closed or not). It contains the object returned by attachInternals(). If the element has been defined to be a form control (through define.form()) this also exposes the features related to that such as setFormValue().

<style>

Because you're working with custom elements, styles are scoped. This means that the styles you write here only apply to the elements in your <template> and don't bleed out to other elements. To make things a little nicer to look at, Yozo will not directly put the <style> element in the template; instead, it creates a stylesheet object and puts it onto the shadow root. That way, when inspecting elements, you won't have to look at walls of CSS.

Notes

  • (Mostly so I don't forget this,) to minify, install esbuild and run deno bundle ./index.js | esbuild --minify > ./index.min.js in the project's root. Then, find the bit where it hardcoded a file:// url and replace that bit so it reads from import.meta.