View on GitHub

Enhance Workshop

This is the Enhance Workshop

Module Index

Module 3: Custom Elements and Web Components

Objectives

The web needs a component model

The platform’s native component model

Web Component Definition

Web Component Example

Checkout the Module 3 code as follows:

cd enhance-workshop
git checkout module03-start

Now add the following code to app/pages/wc.html

  <user-card role="Developer">
    John Doe
    <span slot="email">john.doe@example.com</span>
  </user-card>
  <script>
    class UserCard extends HTMLElement {
      constructor() {
        super();
        let shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `
          <style>
            .card {
              width: 200px;
              border: 1px solid black;
              padding: 10px;
              margin: 10px;
            }
          </style>
          <div class="card">
            <h2><slot></slot></h2>
            <p>Email: <slot name="email"></slot></p>
            <p>Role: ${this.getAttribute('role')}</p>
          </div>
        `;
      }
    }
    customElements.define('user-card', UserCard);
  </script>

Problems with Web Components

JavaScript dependent

The Shadow DOM

<form action="/" method="post">
    <fieldset>
        <legend>Shadow DOM</legend>
        <my-input label="Shadow Input"></my-input>
    </fieldset>
    <fieldset>
        <legend>Light DOM</legend>
        <label>Light Input
            <input/>
        </label>
    </fieldset>
</form>

<script>
  class MyInput extends HTMLElement {
    constructor() {
      super()

      let shadow = this.attachShadow({mode: 'open'})

      shadow.innerHTML = `
        <label>${this.getAttribute('label')}
          <input/>
        </label>
      `
    }
  }

  customElements.define('my-input', MyInput)
</script>
<form action="/" method="post">
  <my-input label="thing">
    <label>Thing
       <input/>
    </label>
  </my-input>
</form>

This is where Enhance shines

Enhance = Web Components The Good Parts

Enhance Elements

Now we can use Enhance elements to add the navigation bar component from the last module. An Enhance element is a single file custom element with some special handling by Enhance. It does a few things to improve page performance, but there is very little magic here. You could cut and paste this code into your HTML for every instance of the <nav-bar> and this would work as expected. This is just standard platform HTML, CSS and JavaScript.

Slotting Children

Attributes and children make up the primary composition API for HTML. Web components and Enhance elements use the same model. This keeps the cognitive load for using them low. Some frameworks do things like change attributes to allow for values other than strings. This seems convenient but can lead to confusion. Enhance sticks to the HTML conventions as much as possible.

Slots (using the <slot> tag) are how web components manage children. Unfortunately the <slot> behavior only happens in the shadow DOM.

This is where Enhance server side expansion fills a major hole in the platform API

Lets build our first Enhance Component to see how it works. We need a container to apply some styles to for the navigation so lets add the following code to /app/elements/site-container.mjs.

// /app/elements/site-container.mjs
export default function SiteContainer({ html }) {
  return html`
    <slot></slot>
  `
}

The file name site-container.mjs is how enhance infers the name of the component <site-container>.

If you have folders nested inside the elements folder they will be concatenated to create the element name. /app/elements/my/heading.mjs -> <my-heading>

Note: all custom elements need an - as part of the tag name. For example my-input is a valid name while myinput is invalid.

This component uses the <slot> to indicate where children should be put. The slot itself will be replaced by the children. Enhance will run this expansion on the server so that the initial children will already be slotted in without waiting for JavaScript to initialize on the client.

Slots can be named as well.

// /app/elements/site-container.mjs

export default function SiteContainer({ html }) {
  return html`
    <slot name="header">Default Heading</slot>
    <slot></slot>
  `
}

Children with the slot=header will be slotted into the <slot name=header>. They can also have default content inside the <slot> tag which will appear only if there is no matching content.

Basic rules for slotting:

  1. Text child nodes go in the unnamed slot.
  2. Children with no slot attribute go in the unnamed slot.
  3. Only direct children can use named slots.
  4. Multiple children with the same named slot are appended in order.
  5. Default content inside the <slot> tag is shown if no content is slotted.

To learn more about slotting the javascript.info site has a good explanation. Just remember that on their own slots only work in the shadow DOM. Enhance SSR is what allows us to use them without it.

Scoped Styles in Components

Lets go back to our simple <site-container> wrapper. We want to apply some styles to the slotted content.

Custom Elements are treated as inline elements by default (basically a span)

But we want our wrapped content to be set to block. We could use the utility styles to set class=block on every usage but we want to wrap it up in the component.

Enhance lets us add style tags inside components.

It does a couple of really helpful things with those styles:

  1. It lifts them to the document head for performance.
  2. It deduplicates so that only one occurrence of each of the styles is needed.
  3. The rules in the tag are scoped to the element by prepending the element name to each rule.
  4. Shadow style rules like :host and :slotted() are changed to equivalent non-shadow CSS.

The CSS shadow rules are a really useful shorthand:

Part is included but not recommend as it generally causes confusion.

So lets make use of this scoped style block to add a few things that our designer tells us will look nice. Copy and paste the styles here into our site-container file.

// /app/elements/site-container.mjs

export default function SiteContainer({ html }) {
  return html`
    <style>
      :host {
        display: block;
        inline-size: var(--site-width);
        max-inline-size: var(--site-max-width);
        margin-inline: auto;
      }
    </style>
    <slot></slot>
  `
}

Script Tags

Now we have components to DRY up our nav bar from the last module. Lets rewrite that the nav bar using these tools.

export default function NavBar({ html }) {
  return html`
    <style>
      :host {
        display: block;
        position: relative;
      }

      .backdrop {
        backdrop-filter: blur(2px);
        background: hsla(0deg 0% 100% / 0.9);
        --mask-image: linear-gradient(to bottom, black 50%, transparent);
        mask-image: var(--mask-image);
        -webkit-mask-image: var(--mask-image);
        inset-block-end: -20%;
      }
    </style>
    <site-container>
      <nav class='flex align-items-center gap0 leading1'>
        <a href='/' class='no-underline flex align-items-center gap0'>
          <h1 class='font-semibold tracking-1'>
            Axol Lotl<br />
            <span class='font-normal'>Web Developer</span>
          </h1>
        </a>
        <ul class='mis-auto flex gap0 list-none text-1 uppercase tracking1 font-semibold'>
          <li><a href='/'>Home</a></li>
          <li><a href='/resume'>Résumé</a></li>
        </ul>
      </nav>
      <div class='backdrop absolute inset-0 z-1'></div>
    </site-container>
  `
}

Authoring with Custom Elements

Now that we have a <nav-bar> defined in /app/elements lets simplify our résumé page. We will use that nice site-container again to wrap our résumé.

<!-- /app/pages/resume.html--->
<nav-bar class='pb4 sticky inset-bs-0 z1'></nav-bar>
<site-container>
    <h1 class='mb6 text5 font-light text-center tracking-2'>
      Résumé
    </h1>
</site-container>

and our home page;

<!-- /app/pages/index.html--->
<nav-bar class='pb4 sticky inset-bs-0 z1'></nav-bar>
<site-container>
    <h1 class='mb6 text5 font-light text-center tracking-2'>
      Home
    </h1>
</site-container>

Congratulations! We are not done.

But we are getting closer. Next we need to add some data to our Résumé. And generally smarten up some of these components.

In the next module we will talk about API routes and how we can pass data around in a few different ways.