Building a HTML Element Sandbox with Lit
In this article I will go over how to set up a Lit web component and use it to create a HTML Element sandbox that can be used to update a live component.
TLDR The final source here and an online demo.
Prerequisites
-
Vscode
-
Node >= 16
-
Typescript
Getting Started
We can start off by navigating in terminal to the location of the project and run the following:
npm init @vitejs/app --template lit-ts
Then enter a project name html-element-sandbox and now open the project in vscode and install the dependencies:
cd html-element-sandbox npm i lit npm i -D @types/node code .
Update the vite.config.ts with the following:
import { defineConfig } from "vite"; import { resolve } from "path";
export default defineConfig({ base: "/html-element-sandbox/", build: { lib: { entry: "src/html-element-sandbox.ts", formats: ["es"], }, rollupOptions: { input: { main: resolve(__dirname, "index.html"), }, }, }, });
Template
Open up the index.html and update it with the following:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>HTML Element Sandbox</title> <script type="module" src="/src/html-element-sandbox.ts"></script> <style> body { margin: 0; padding: 0; font-family: sans-serif; } html-element-sandbox { display: block; width: 100%; height: 100vh; } </style> </head> <body> <html-element-sandbox> <template> <button class="button" knob-text="label" knob-css-color="fg-color" knob-css-background-color="bg-color" knob-css-border-radius="shape" knob-css-font-size="text-font-size" knob-css-padding="padding" knob-css---shadow-color="shadow" > My Button </button> <style> .button { --shadow-color: #000; --elevation: 3px; display: block; width: 100%; height: 100%; border: none; background-color: transparent; cursor: pointer; box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color); } </style> </template> <div slot="knobs"> <knob-string id="label" name="Label" value="BUTTON"></knob-string> <knob-group name="Style" expanded> <knob-color id="bg-color" name="Background Color" value="#ff0000" ></knob-color> <knob-color id="fg-color" name="Foreground Color" value="#ffffff" ></knob-color> <knob-color id="shadow" name="Shadow Color" value="#000000" ></knob-color> <knob-number id="text-font-size" name="Font Size" value="16" suffix="px" ></knob-number> <knob-number id="shape" name="Border Radius" value="100" suffix="px" ></knob-number> <knob-number id="padding" name="Padding" value="12" suffix="px" ></knob-number> </knob-group> </div> </html-element-sandbox> </body> </html>
Here we are defining the markup we want to use in our sandbox. We are using the html-element-sandbox component to create a sandbox for our HTML Element.
<html-element-sandbox></html-element-sandbox>
Each knob is defined by an id and a name . The id is used to identify the knob in the template and the name is used to display the knob in the UI.
<knob-number id="shape" name="Border Radius" value="30" suffix="px"
</knob-number>
For the element inside the template we use knob-* attributes to get the values of the knobs and set the attributes, CSS style or text content.
<!-- Attributes --> <div knob-attr-disabled="disabled"></div> <knob-boolean id="disabled" name="Disable" value="false"></knob-boolean>
<!-- CSS Properties --> <div knob-css-color="fg-color" knob-css-background-color="bg-color"></div> <knob-color id="bg-color" name="Background Color" value="#ff0000"></knob-color> <knob-color id="fg-color" name="Foreground Color" value="#ffffff"></knob-color>
<!-- Text Content --> <div knob-text="content"></div> <knob-string id="content" name="Text Content" value="Hello World"></knob-string>
A single knob can point to multiple elements:
<html-element-sandbox> <template> <div id="buttons"> <button knob-text="label" knob-css-color="fg-color" knob-css-background-color="bg-color" knob-css-border-radius="shape" knob-css-font-size="text-font-size" knob-css-padding="padding" knob-css---shadow-color="shadow" knob-attr-raised="raised" knob-attr-contenteditable="contenteditable" ></button> <mwc-button knob-attr-label="label" knob-css---mdc-theme-on-primary="fg-color" knob-css---mdc-theme-primary="bg-color" knob-css---mdc-shape-small="shape" knob-attr-raised="raised" label="My Button" ></mwc-button> </div> <script type="module"> import "https://www.unpkg.com/@material/[email protected]/mwc-button.js?module"; </script> <style> button { --shadow-color: #000; --elevation: 3px; display: block; border: none; background-color: transparent; cursor: pointer; box-shadow: 0 var(--elevation) calc(var(--elevation) * 2) 0 var(--shadow-color); } mwc-button { --mdc-theme-on-primary: #000; --mdc-theme-primary: #fff; --mdc-shape-small: none; } #buttons { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 1rem; } </style> </template> <div slot="knobs"> <knob-string id="label" name="Label" value="BUTTON"></knob-string> <knob-group name="Style" expanded> <knob-color id="bg-color" name="Background Color" value="#ff0000" ></knob-color> <knob-color id="fg-color" name="Foreground Color" value="#ffffff" ></knob-color> <knob-color id="shadow" name="Shadow Color" value="#000000"></knob-color> <knob-number id="text-font-size" name="Font Size" value="16" suffix="px" ></knob-number> <knob-number id="shape" name="Border Radius" value="30" suffix="px" ></knob-number> <knob-number id="padding" name="Padding" value="12" suffix="px" ></knob-number> </knob-group> <knob-group name="Attributes" expanded> <knob-boolean id="raised" name="Raised" value="false"></knob-boolean> <knob-list id="contenteditable" name="Content Editable" value="false"> <option value="true">true</option> <option value="false">false</option> </knob-list> </knob-group> </div> </html-element-sandbox>
A style and script can be added to load extra content into the sandbox (e.g. a script to load a web component).
Web Component
Before we update our component we need to rename my-element.ts to html-element-sandbox.ts
Open up html-element-sandbox.ts and update it with the following:
import { css, html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js";
import "./knobs/boolean"; import "./knobs/string"; import "./knobs/number"; import "./knobs/color"; import "./knobs/list"; import "./knobs/group"; import { KnobValue } from "./knobs/base"; import { BooleanKnob } from "./knobs/boolean";
export const tagName = "html-element-sandbox";
@customElement(tagName)
export class HTMLElementSandbox extends LitElement {
static styles = css main { --knobs-width: 300px; --code-height: calc(100% * 0.4); --mobile-height: 350px; display: grid; grid-template-areas: "preview" "knobs" "code"; grid-template-columns: 100%; grid-template-rows: var(--mobile-height) auto auto; height: 100%; width: 100%; } #preview { grid-area: preview; display: flex; flex-direction: column; align-items: center; justify-content: center; border-bottom: 1px solid #272727; background-color: whitesmoke; } @media (min-width: 600px) { main { grid-template-areas: "preview knobs" "code knobs"; grid-template-columns: calc(100% - var(--knobs-width)) var( --knobs-width ); grid-template-rows: calc(100% - var(--code-height)) var(--code-height); } #preview { border-bottom: none; } slot[name="knobs"] { overflow-y: auto; } pre { overflow-y: scroll; } } section { flex: 1; } slot[name="knobs"] { grid-area: knobs; display: flex; flex-direction: column; border-left: 1px solid #000; } slot[name="code"] { grid-area: code; } pre { margin: 0; font-family: Monaco, Courier, monospace; padding: 16px; background-color: #272727; color: #c8c8c8; } code { font-size: 0.8rem; white-space: pre-wrap; } ;
@state() code = "";
render() {
return html<main> <section id="preview"> <slot></slot> </section> <slot name="knobs"> </slot> <slot name="code"> <pre><code>${this.code}</code></pre> </slot> </main>;
}
firstUpdated() { this.init(); }
init() { this.setUpKnobs(); this.code = this.getCode(); // Update the code every time a knob value changes this.addEventListener("value", () => { this.code = this.getCode(); }); }
setUpKnobs() {
const root = this.shadowRoot!;
const preview = root.getElementById("preview")!;
const template = this.querySelector("template");
if (template) {
const div = document.createElement("div");
div.appendChild(template.content.cloneNode(true));
// Text Knobs (knob-text)
div.querySelectorAll("[knob-text]").forEach((el) => {
const elemId = el.getAttribute("knob-text") || "";
const knob = this.querySelector(#${elemId});
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
el.textContent = val;
});
el.addEventListener("input", (e) => {
const target = e.target as HTMLElement;
knob.value = target.textContent;
});
knob.init();
}
});
div.querySelectorAll("").forEach((el) => {
const attrs = el.attributes;
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
const attrName = attr.name;
// CSS Knobs (knob-css-)
if (attrName.startsWith("knob-css-")) {
const cssKey = attrName.replace("knob-css-", "");
const knob = this.querySelector(#${attr.value});
if (
knob &&
knob instanceof KnobValue &&
el instanceof HTMLElement
) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob.hasAttribute("suffix")) {
// Add suffix to the value (e.g. px)
el.style.setProperty(
cssKey,
val + knob.getAttribute("suffix")
);
} else {
// No suffix, just set the value
el.style.setProperty(cssKey, val);
}
});
knob.init();
}
}
// Attribute Knobs (knob-attr-*)
if (attrName.startsWith("knob-attr-")) {
const attrKey = attrName.replace("knob-attr-", "");
const knob = this.querySelector(#${attr.value});
if (knob && knob instanceof KnobValue) {
knob.addEventListener("value", () => {
const val = knob.value;
if (knob instanceof BooleanKnob) {
if (val) {
// <div hidden>
el.setAttribute(attrKey, "");
} else {
// <div>
el.removeAttribute(attrKey);
}
} else {
// <div value="foo">
el.setAttribute(attrKey, val);
}
});
knob.init();
}
}
}
});
preview.appendChild(div);
}
}
getCode() { const root = this.shadowRoot!; const preview = root.getElementById("preview")!; if (preview.children.length > 0) { const child = preview.children[1]; if (child && child.children.length > 0) { const lines = this.elementToString(child.children[0]); // Trim empty lines const linesArray = lines.split("\n"); const filteredLines = linesArray.filter((line) => line.trim() !== ""); return filteredLines.join("\n"); } } return ""; }
elementToString(node: Element) {
const sb: string[] = [];
const tag = node.tagName.toLowerCase();
sb.push(<${tag});
const attrs = node.attributes;
// Add attributes
for (let i = 0; i < attrs.length; i++) {
const attr = attrs[i];
if (attr.name.startsWith("knob-")) continue;
// If the attribute is a boolean attribute, add it only if it's true
if (attr.value === "") {
sb.push( ${attr.name});
} else {
sb.push( ${attr.name}="${attr.value}");
}
}
sb.push(">");
if (node.childNodes.length > 0) {
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
// If the child is a text node, add the content
if (child instanceof Text) {
sb.push(child.textContent || "");
} else if (child instanceof Element) {
// If the child is an element, recurse
sb.push(this.elementToString(child));
}
}
}
sb.push(</${tag}>);
return sb.join("\n");
}
}
declare global { interface HTMLElementTagNameMap { [tagName]: HTMLElementSandbox; } }
Knobs
First let up create a base class that will be used to create all other knobs. Create src/knobs/base.ts and update with with the following:
import { css, html, LitElement, TemplateResult } from "lit"; import { property } from "lit/decorators.js";
export class Knob extends LitElement { constructor(name: string) { super(); this.name = name; }
@property() name: string; }
export abstract class KnobValue<T> extends Knob { constructor(name: string, public val: T) { super(name); this._value = val; this.notify(); }
static styles = css .knob { display: flex; flex-direction: row; align-items: center; padding: 0.5rem; } .knob label { flex: 1; } ;
_value: T;
get value(): T { return this._value; }
set value(value: T) { this._value = value; this.notify(); }
notify() { const value = this.value; this.onValue(value); this.dispatchEvent( new CustomEvent("value", { detail: value, bubbles: true, composed: true, }) ); this.requestUpdate(); }
render() {
return html <div class="knob"> <label>${this.name}</label> ${this.buildInput()} </div> ;
}
onValue(_val: T) {}
init() { this.notify(); }
resolveValue(val: T) { return val; }
abstract buildInput(): TemplateResult; }
Boolean knob
Create src/knobs/boolean.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit"; import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-boolean";
@customElement(tagName) export class BooleanKnob extends KnobValue<boolean> { constructor(name: string, val: boolean) { super(name, val); }
static styles = KnobValue.styles;
@property({ type: Boolean, attribute: "value", }) _value = false;
buildInput() {
return html<input type="checkbox" .checked=${this.resolveValue(this.value)} @change=${this.onChange} />;
}
onChange(e: Event) { const target = e.target as HTMLInputElement; this.value = target.checked; } }
declare global { interface HTMLElementTagNameMap { [tagName]: BooleanKnob; } }
Number Knob
Create src/knobs/number.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit"; import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-number";
@customElement(tagName) export class NumberKnob extends KnobValue<number> { constructor(name: string, val: number) { super(name, val); }
static styles = KnobValue.styles;
@property({ type: Number, attribute: "value", converter: { fromAttribute: (val: string) => parseFloat(val), toAttribute: (val: boolean) => val.toString(), }, }) _value = 0;
buildInput() {
return html<input type="number" .valueAsNumber=${this.resolveValue(this.value)} @change=${this.onChange} />;
}
onChange(e: Event) { const target = e.target as HTMLInputElement; this.value = target.valueAsNumber; }
resolveValue(val: number): number { return val; } }
declare global { interface HTMLElementTagNameMap { [tagName]: NumberKnob; } }
String Knob
Create src/knobs/string.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit"; import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-string";
@customElement(tagName) export class StringKnob extends KnobValue<string> { constructor(name: string, val: string) { super(name, val); }
static styles = KnobValue.styles;
@property({ type: String, attribute: "value" }) _value = "";
buildInput() {
return html<input type="text" .value=${this.resolveValue(this.value)} @input=${this.onChange} />;
}
onChange(e: Event) { const target = e.target as HTMLInputElement; this.value = target.value; } }
declare global { interface HTMLElementTagNameMap { [tagName]: StringKnob; } }
Color Knob
Create src/knobs/color.ts and update with the following:
import { html } from "lit"; import { customElement } from "lit/decorators.js"; import { StringKnob } from "./string";
export const tagName = "knob-color";
@customElement(tagName)
export class ColorKnob extends StringKnob {
buildInput() {
return html<input type="color" .value=${this.resolveValue(this.value)} @input=${this.onChange} />;
}
resolveValue(value: string) { if (value && value.startsWith("--")) { const style = getComputedStyle(document.body); const resolved = style.getPropertyValue(value); return resolved; } return value; } }
declare global { interface HTMLElementTagNameMap { [tagName]: ColorKnob; } }
List Knob
Create src/knobs/list.ts and update with the following:
import { KnobValue } from "./base";
import { html } from "lit"; import { customElement, property } from "lit/decorators.js";
export const tagName = "knob-list";
@customElement(tagName) export class ListKnob extends KnobValue<string> { constructor(name: string, val: string) { super(name, val); }
static styles = KnobValue.styles;
@property({ type: String, attribute: "value", }) _value = "";
buildInput() {
const options = this.getOptions();
return html<select @change=${this.onChange}> ${Array.from(options).map( (option) => html<option
value=${option.value}
.selected=${this.value === option.value}
>
${option.textContent}
</option> )} </select>;
}
getOptions() { const options = this.querySelectorAll( "option" ) as NodeListOf<HTMLOptionElement>; return Array.from(options); }
onChange(e: Event) { const target = e.target as HTMLSelectElement; this.value = target.value; } }
declare global { interface HTMLElementTagNameMap { [tagName]: ListKnob; } }
Group Knob
Create src/knobs/group.ts and update with the following:
import { css, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Knob } from "./base";
export const tagName = "knob-group";
@customElement(tagName) export class GroupKnob extends Knob { constructor(name: string, knobs: Knob[] = []) { super(name); this.knobs = knobs; }
static styles = css details { display: flex; flex-direction: column; align-items: flex-start; } details summary { padding: 0.5rem; } ;
knobs: Knob[];
@property({ type: Boolean }) expanded = false;
render() {
return html<details ?open=${this.expanded}> <summary>${this.name}</summary> <div class="collection"> <slot></slot> ${this.knobs.map((knob) => html${knob})} </div> </details>;
}
}
declare global { interface HTMLElementTagNameMap { [tagName]: GroupKnob; } }
Conclusion
If everything worked as expected, you should see the following:
If you want to learn more about building with Lit you can read the docs here.
The source for this example can be found here.