HTML Custom Elements

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

1
<!DOCTYPE html>
2
<html lang="en">
3
<head>
4
<meta charset="UTF-8" />
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
<title>Web Components Playground</title>
7
<!-- Custom element script needs to be deferred due to the initialization lifecycle -->
8
<script src="main.js" defer></script>
9
<!-- Showdown used for markdown conversion -->
10
<script src="https://cdn.jsdelivr.net/npm/showdown@2.1.0/dist/showdown.min.js"></script>
11
</head>
12
<body>
13
<h1>A little markdown editor</h1>
14
15
<app-contenteditable
16
onchange="console.log(event)"
17
label="Custom Element Input"
18
></app-contenteditable>
19
</body>
20
</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

1
// @ts-check
2
3
const html = String.raw
4
5
/**
6
* @typedef {Object} Converter
7
* @property {(markdown: string) => string} makeHtml
8
*/
9
10
/**
11
* @typedef {new () => Converter} ConverterConstructor
12
*/
13
14
/**
15
* @typedef {Object} Showdown
16
* @property {ConverterConstructor} Converter
17
*/
18
19
/** @type {Showdown} */
20
const showdown = window["showdown"]
21
22
class MarkdownPreviewElement extends HTMLElement {
23
static selector = "app-contenteditable"
24
25
#converter = new showdown.Converter()
26
27
/** @type {string} */
28
get #label() {
29
return this.getAttribute("label") || ""
30
}
31
32
/** @type {string} */
33
get #value() {
34
return this.getAttribute("value") || ""
35
}
36
37
/** @type {HTMLDivElement} */
38
#wrapper
39
40
get #input() {
41
const el = /** @type {HTMLTextAreaElement} */ (
42
this.shadowRoot?.getElementById("input")
43
)
44
45
return el
46
}
47
48
get #output() {
49
const el = /** @type {HTMLDivElement} */ (
50
this.shadowRoot?.getElementById("output")
51
)
52
53
return el
54
}
55
56
#onchange() {
57
const markdown = this.#input.value
58
const html = this.#converter.makeHtml(markdown)
59
60
this.#output.innerHTML = html
61
62
const event = new CustomEvent("change", {
63
detail: {
64
markdown,
65
html,
66
},
67
})
68
69
this.onchange?.(event)
70
}
71
72
/** @type {string} */
73
get #content() {
74
return html`
75
<h2>Markdown</h2>
76
<textarea id="input" value="${this.#value}"></textarea>
77
<h2>HTML</h2>
78
<div id="output"></div>
79
`
80
}
81
82
constructor() {
83
super()
84
this.#wrapper = document.createElement("div")
85
this.#wrapper.className = MarkdownPreviewElement.selector
86
87
this.#wrapper.innerHTML = this.#content
88
89
const shadow = this.attachShadow({ mode: "open" })
90
shadow.appendChild(this.#wrapper)
91
92
this.#input.addEventListener("input", () => this.#onchange())
93
this.#output.innerHTML = this.#converter.makeHtml(this.#value)
94
}
95
}
96
97
customElements.define(MarkdownPreviewElement.selector, MarkdownPreviewElement)