building-a-html-element-sandbox-with-lit

Building a HTML Element Sandbox with Lit

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "building-a-html-element-sandbox-with-lit" with this command: npx skills add rodydavis/skills/rodydavis-skills-building-a-html-element-sandbox-with-lit

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&#x3C;main> &#x3C;section id="preview"> &#x3C;slot>&#x3C;/slot> &#x3C;/section> &#x3C;slot name="knobs"> &#x3C;/slot> &#x3C;slot name="code"> &#x3C;pre>&#x3C;code>${this.code}&#x3C;/code>&#x3C;/pre> &#x3C;/slot> &#x3C;/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(&#x3C;${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(&#x3C;/${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 &#x3C;div class="knob"> &#x3C;label>${this.name}&#x3C;/label> ${this.buildInput()} &#x3C;/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&#x3C;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&#x3C;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&#x3C;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&#x3C;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&#x3C;select @change=${this.onChange}> ${Array.from(options).map( (option) => html<option value=${option.value} .selected=${this.value === option.value} > ${option.textContent} </option> )} &#x3C;/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&#x3C;details ?open=${this.expanded}> &#x3C;summary>${this.name}&#x3C;/summary> &#x3C;div class="collection"> &#x3C;slot>&#x3C;/slot> ${this.knobs.map((knob) => html${knob})} &#x3C;/div> &#x3C;/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.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

flutter-control-and-screenshot

No summary provided by upstream source.

Repository SourceNeeds Review
General

install-flutter-from-git

No summary provided by upstream source.

Repository SourceNeeds Review
General

how-to-build-a-native-cross-platform-project-with-flutter

No summary provided by upstream source.

Repository SourceNeeds Review
General

how-to-build-a-webrtc-signal-server-with-pocketbase

No summary provided by upstream source.

Repository SourceNeeds Review