HTML Custom Elements

Created:

Updated: 14 November 2023

Below is a small example showing an HTML Custom Element that works as a markdown input and preview. The HTML file renders the element using the respective tag:

index.html

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>Web Components Playground</title>
		<!-- Custom element script needs to be deferred due to the initialization lifecycle -->
		<script src="main.js" defer></script>
		<!-- Showdown used for markdown conversion -->
		<script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
	</head>
	<body>
		<h1>A little markdown editor</h1>

		<app-contenteditable
			onchange="console.log(event)"
			label="Custom Element Input"
		></app-contenteditable>
	</body>
</html>

And the Javascript implementation is as follows:

  • TS Check declaration used to get type checking on the JS file with the relevant JSDoc
  • JS Doc used to provide better class level type intferrence
  • Private class members in JS using #
  • The HTML html=String.raw used for syntax highlighting of HTML strings

main.js

// @ts-check

const html = String.raw

/**
 * @typedef {Object} Converter
 * @property {(markdown: string) => string} makeHtml
 */

/**
 * @typedef {new () => Converter} ConverterConstructor
 */

/**
 * @typedef {Object} Showdown
 * @property {ConverterConstructor} Converter
 */

/** @type {Showdown} */
const showdown = window["showdown"]

class MarkdownPreviewElement extends HTMLElement {
	static selector = "app-contenteditable"

	#converter = new showdown.Converter()

	/** @type {string} */
	get #label() {
		return this.getAttribute("label") || ""
	}

	/** @type {string} */
	get #value() {
		return this.getAttribute("value") || ""
	}

	/** @type {HTMLDivElement} */
	#wrapper

	get #input() {
		const el = /** @type {HTMLTextAreaElement} */ (
			this.shadowRoot?.getElementById("input")
		)

		return el
	}

	get #output() {
		const el = /** @type {HTMLDivElement} */ (
			this.shadowRoot?.getElementById("output")
		)

		return el
	}

	#onchange() {
		const markdown = this.#input.value
		const html = this.#converter.makeHtml(markdown)

		this.#output.innerHTML = html

		const event = new CustomEvent("change", {
			detail: {
				markdown,
				html,
			},
		})

		this.onchange?.(event)
	}

	/** @type {string} */
	get #content() {
		return html`
			<h2>Markdown</h2>
			<textarea id="input" value="${this.#value}"></textarea>
			<h2>HTML</h2>
			<div id="output"></div>
		`
	}

	constructor() {
		super()
		this.#wrapper = document.createElement("div")
		this.#wrapper.className = MarkdownPreviewElement.selector

		this.#wrapper.innerHTML = this.#content

		const shadow = this.attachShadow({ mode: "open" })
		shadow.appendChild(this.#wrapper)

		this.#input.addEventListener("input", () => this.#onchange())
		this.#output.innerHTML = this.#converter.makeHtml(this.#value)
	}
}

customElements.define(MarkdownPreviewElement.selector, MarkdownPreviewElement)