Building a Design System and Consuming it in Multiple Frameworks using Stencil.js

0
95
Building a Design System and Consuming it in Multiple Frameworks using Stencil.js


Large organizations with multiple software development departments may find themselves supporting multiple web frameworks across the organization. This can make it challenging to keep the brand consistent across applications as each application is likely implementing its own solution to the brand. What if we could create a design system that could be used across multiple frameworks? Other organizations may find themselves implementing similar components in various brands for various clients. What if the design system could also be white-labeled?

This blog post will be exploring these questions by building a design system with Stencil.js. The design system will support white-labeling via CSS Variables. Then the design system will be used without a framework, and with the ReactVueSvelte, and Stencil frameworks.

Stencil is a compiler for web components that can build custom elements for use across multiple frameworks. The compatibility of custom elements with various frameworks is tracked by Custom Elements Everywhere.

The code for this blog can be found on GitHub at https://github.com/BNR-Developer-Sandbox/BNR-blog-stencil-design-system.

Each component encapsulates its own CSS and functionality. Developers wanting to publish a web component library can follow the Getting started guide for Stencil. The Stencil API provides decorators and lifecycle hooks to reduce boilerplate code and define implementation patterns.

decorators that are removed at compile time.

Each Stencil Component uses the @Component() decorator to declare a new web component defining the tag, styleUrl, and if the Shadow DOM should be used or not.

@Component({
  tag: "ds-form",
  styleUrl: "ds-form.css",
  shadow: true,
})

render() method which uses JSX to return a tree of components to render at runtime.

ds-shell component provides a header, main, and footer section available via slots. The header and footer slots are named slots, while the main content area uses the default unnamed slot. This component provides the general layout for an application.

import { Component, Host, h } from "@stencil/core";

@Component({
  tag: "ds-shell",
  styleUrl: "ds-shell.css",
  shadow: true,
})
export class DsShell {
  render() {
    return (
      <Host>
        <header>
          <slot name="header"></slot>
        </header>
        <main>
          <slot></slot>
        </main>
        <footer>
          <slot name="footer"></slot>
        </footer>
      </Host>
    );
  }
}

ds-hero component provides a default slot container and encapsulates the CSS for the component.

import { Component, Host, h } from "@stencil/core";

@Component({
  tag: "ds-hero",
  styleUrl: "ds-hero.css",
  shadow: true,
})
export class DsHero {
  render() {
    return (
      <Host>
        <slot></slot>
      </Host>
    );
  }
}

ds-form component provides generic form handling by listening for inputchange, and click events that bubble up to the form element. The component uses the @Element() decorator to declare a reference to the host element in order to dispatch the formdata event from the host element when the form is submitted. The component also uses the @State() decorator to declare the internal state of the form data.

import { Component, Element, Host, State, h } from "@stencil/core";

@Component({
  tag: "ds-form",
  styleUrl: "ds-form.css",
  shadow: true,
})
export class DsForm {
  @Element() el: HTMLElement;
  @State() data: any = {};
  onInput(event) {
    const { name, value } = event.target;
    this.data[name] = value;
  }
  onChange(event) {
    const { name, value } = event.target;
    this.data[name] = value;
  }
  onClick(event) {
    if (event?.target?.type === "submit") {
      const formData = new FormData();
      Object.entries(this.data).forEach(([key, value]) => {
        formData.append(key, String(value));
      });
      const formDataEvent = new FormDataEvent("formdata", { formData });
      this.el.dispatchEvent(formDataEvent);
    }
  }
  render() {
    return (
      <Host>
        <form
          // form event
          onInput={(event) => this.onInput(event)}
          onChange={(event) => this.onChange(event)}
          onClick={(event) => this.onClick(event)}
        >
          <slot></slot>
        </form>
      </Host>
    );
  }
}

In order to implement white-labeling, CSS Variables are used in the components which can be set by the design system consumer. The example repository only sets three variables to illustrate the idea; a complete design system would likely include more variables including sizes, fonts, etc.

Each application defines CSS Variables to implement a different brand.

:root {
  /* CSS Variables for theming white-labeled components */
  --primary-color: red;
  --secondary-color: gold;
  --tertiary-color: green;
}

Each application also defines a simple CSS Reset.

body {
  margin: 0;
  background-color: var(--tertiary-color);
}

While consuming the custom elements the same HTML structure was used in each framework. Each example application implemented in each framework composes the ds-shell component for application layout, with a ds-hero to provide a call-to-action content area, and a unique form and form handler to process the form.

<ds-shell>
  <h1 slot="header">App Name</h1>
  <ds-hero>
    <ds-form>
      <label>
        Your Name:
        <br />
        <input type="text" name="name" />
      </label>
      <br />
      <label>
        Your Expertise:
        <br />
        <input type="text" name="expertise" />
      </label>
      <br />
      <input type="submit" value="Say Hello" />
    </ds-form>
  </ds-hero>
  <span slot="footer">
    <span>1</span>
    <span>2</span>
    <span>3</span>
  </span>
</ds-shell>

index.html. CSS Variables are defined in a <style> tag in the <head> tag. The event listener is added in a <script> tag at the end of the <body> tag.

<!DOCTYPE html>
<html dir="ltr" lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0"
    />
    <title>Design System</title>

    <script type="module" src="https://bignerdranch.com/build/design-system.esm.js"></script>
    <script nomodule src="/build/design-system.js"></script>
    <style type="text/css">
      :root {
        /* CSS Variables for theming white-labeled components */
        --primary-color: red;
        --secondary-color: gold;
        --tertiary-color: green;
      }

      body {
        margin: 0;
        background-color: var(--tertiary-color);
      }
    </style>
  </head>
  <body>
    <ds-shell>
      <h1 slot="header">App Name</h1>
      <ds-hero>
        <ds-form>
          <label>
            Your Name:
            <br />
            <input type="text" name="name" />
          </label>
          <br />
          <label>
            Your Expertise:
            <br />
            <input type="text" name="expertise" />
          </label>
          <br />
          <input type="submit" value="Say Hello" />
        </ds-form>
      </ds-hero>
      <span slot="footer">
        <span>1</span>
        <span>2</span>
        <span>3</span>
      </span>
    </ds-shell>

    <script>
      function handleFormData(event) {
        event.stopPropagation();
        const { formData } = event;
        const data = Object.fromEntries(formData);
        const { name, expertise } = data;
        alert(`Hello ${name}, I hear you are good at ${expertise}.`);
      }

      const form = document.getElementsByTagName("ds-form")[0];
      form.addEventListener("formdata", (event) => handleFormData(event));
    </script>
  </body>
</html>

In order to add the event listener without a framework the native addEventListener method is used.

form.addEventListener("formdata", (event) => handleFormData(event));

App.js with CSS Variables in index.css.

import "design-system/ds-shell";
import "design-system/ds-hero";
import "design-system/ds-form";

function handleFormData(event) {
  event.stopPropagation();
  const { formData } = event;
  const data = Object.fromEntries(formData);
  const { reaction } = data;
  alert(`I am surprised that you reacted ${reaction}.`);
}

function App() {
  return (
    <ds-shell>
      <h1 slot="header">React App</h1>
      <ds-hero>
        <ds-form onformdata={(event) => handleFormData(event)}>
          <label>
            How did you react?:
            <br />
            <input type="text" name="reaction" />
          </label>
          <br />
          <input type="submit" value="What is your reaction?" />
        </ds-form>
      </ds-hero>
      <span slot="footer">
        <span>1</span>
        <span>2</span>
        <span>3</span>
      </span>
    </ds-shell>
  );
}

export default App;

React uses a lowercase onformdata attribute in JSX to bind the formdata event to a handler.

<ds-form onformdata={(event) => handleFormData(event)}>
  <!-- ... -->
</ds-form>

App.vue with CSS Variables in base.css.

<script setup>
  import "design-system/ds-shell";
  import "design-system/ds-hero";
  import "design-system/ds-form";

  function handleFormData(event) {
    event.stopPropagation();
    const { formData } = event;
    const data = Object.fromEntries(formData);
    const { view } = data;
    alert(`${view}!?! Wow! What a view!`);
  }
</script>

<template>
  <ds-shell>
    <h1 slot="header">Vue App</h1>
    <ds-hero>
      <ds-form @formdata="handleFormData">
        <label>
          How's the view?:
          <br />
          <input type="text" name="view" />
        </label>
        <br />
        <input type="submit" value="Looking good?" />
      </ds-form>
    </ds-hero>
    <span slot="footer">
      <span>1</span>
      <span>2</span>
      <span>3</span>
    </span>
  </ds-shell>
</template>

<style>
  @import "@/assets/base.css";
</style>

Vue uses a lowercase @formdata attribute set to the function name to bind the formdata event to a handler.

<ds-form onformdata={(event) => handleFormData(event)}>
  <!-- ... -->
</ds-form>

main.js.

import App from "./App.svelte";

const app = new App({
  target: document.body,
  props: {
    appName: "Svelte App",
    fieldLabel: "Your Location",
    fieldName: "location",
    submitLabel: "Where you at?",
    handleFormData: (event) => {
      event.stopPropagation();
      const { formData } = event;
      const data = Object.fromEntries(formData);
      const { location } = data;
      alert(`Where's ${location}?`);
    },
  },
});

export default app;

App.svelte uses the variables from main.js to render HTML and bind events.

<script>
  export let appName, fieldLabel, fieldName, submitLabel, handleFormData;
  import "design-system/ds-shell";
  import "design-system/ds-hero";
  import "design-system/ds-form";
</script>

<ds-shell>
  <h1 slot="header">{appName}</h1>
  <ds-hero>
    <ds-form on:formdata="{handleFormData}">
      <label>
        {fieldLabel}:
        <br />
        <input type="text" name="{fieldName}" />
      </label>
      <br />
      <input type="submit" value="{submitLabel}" />
    </ds-form>
  </ds-hero>
  <span slot="footer">
    <span>1</span>
    <span>2</span>
    <span>3</span>
  </span>
</ds-shell>

The CSS Variables are defined in global.css.

Svelte uses a lowercase on:formdata attribute to bind the formdata event to a handler.

<ds-form on:formdata="{handleFormData}">
  <!-- ... -->
</ds-form>

app-root.tsx with CSS Variables in app.css.

import { Component, h } from "@stencil/core";
import "design-system/ds-shell";
import "design-system/ds-hero";
import "design-system/ds-form";

@Component({
  tag: "app-root",
  styleUrl: "app-root.css",
  shadow: true,
})
export class AppRoot {
  handleFormData(event) {
    event.stopPropagation();
    const { formData } = event;
    const data = Object.fromEntries(formData);
    const { expertise } = data;
    alert(`So you are good with ${expertise}...`);
  }
  render() {
    return (
      <ds-shell>
        <h1 slot="header">Stencil App</h1>
        <ds-hero>
          <ds-form onFormData={(event) => this.handleFormData(event)}>
            <label>
              Your Expertise:
              <br />
              <input type="text" name="expertise" />
            </label>
            <br />
            <input type="submit" value="Say Something" />
          </ds-form>
        </ds-hero>
        <span slot="footer">
          <span>1</span>
          <span>2</span>
          <span>3</span>
        </span>
      </ds-shell>
    );
  }
}

Stencil uses a camel case onFormData attribute to bind the formdata event to a handler.

<ds-form onFormData={(event) => this.handleFormData(event)}>
  <!-- -->
</ds-form>

Building web components allows developers to reuse UI elements in multiple frameworks. Starting with a design system, developers are able to develop a basic app shell in React, Vue, Svelte, and Stencil. The compatibility within frameworks is tracked by Custom Elements Everywhere. The application for each framework had different ways to handle events but each project handled imports, html, and CSS similarly. A properly configured Stencil project can be used to publish a component library that is consumed by developers across multiple frameworks.

Source link

Leave a reply

Please enter your comment!
Please enter your name here