This content originally appeared on DEV Community 👩💻👨💻 and was authored by Gabriel José
Don’t forget to check out the first part of this tutorial. In this part I’m going to talk about the change of returning a string from the html tag function to returning a Document Fragment.
To do it we must create a template element, put the html text as its innerHTML
, and then get the document fragment from the content property of the template. We can simple return the Document Fragment for now.
function html(staticText: TemplateStringsArray, ...values: any[]) {
const fullText = staticText.reduce((acc, text, index) => {
const stringValue = getCorrectStringValue(values[index])
return acc + text + stringValue
}, '')
const template = document.createElement('template')
template.innerHTML = fullText
const documentFragment = template.content
return documentFragment
}
Just by doing this you will need to replace the code to add the elements in the DOM.
document.body.innerHTML += html`<p>Hello World</p>`
// Replace to
document.body.append(html`<p>Hello World</p>`)
If you not make this replacement, you’ll notice that will be shown [object DocumentFragment]
will the page instead of the Hello World text and the same happen with elements inserted as elements.
We need a way to catch the document fragment that is been inserted in the tagged template string and insert it into the other document fragment that is going to be created, to do so we’ll use a template element with a custom attribute, a then we’ll replace it for the document fragment it self.
interface ResourceMaps {
elementsMap: Map<string, DocumentFragment>
}
function resolveValue(value: unknown, index: number, resourceMaps: ResourceMaps) {
if (value instanceof DocumentFragment) {
const elementId = `el="el-${index}"`
resourceMaps.elementsMap.set(elementId, value)
return `<template ${elementId}></template>`
}
return getCorrectStringValue(value)
}
function getCorrectStringValue(value: any) {
if (value instanceof HTMLString) {
return value
}
const isUnwantedValue = value === undefined
|| value === null
|| value === false
if (isUnwantedValue) {
return ''
}
return String(value ?? '')
.replace(/\</g, '<')
.replace(/\>/g, '>')
}
function html(staticText: TemplateStringsArray, ...values: any[]) {
const resourceMaps: ResourceMaps = {
elementsMap: new Map()
}
const fullText = staticText.reduce((acc, text, index) => {
const currentValue = values[index]
const stringValue = resolveValue(currentValue, index, resourceMaps)
return acc + text + stringValue
}, '')
const template = document.createElement('template')
template.innerHTML = fullText
const documentFragment = template.content
return documentFragment
}
If you notice, we take the document fragment and place it in a Map
with a key using the index for the inserted value and then we return a string of a template element with that attribute. Since we’re building the full HTML text we need to always return a string, store the value and put some sort of placeholder to later we know where to work on. Many of the future features I’ll show you here will work the same way.
But it is not complete yet, we need to create another function to place those elements we have stored.
function placeElements(docFrag: DocumentFragment, elementsMap: ResourceMaps['elementsMap']) {
for (const [elementId, elements] of elementsMap) {
const placeholder = docFrag.querySelector(`[${elementId}]`)
if (!placeholder) continue
const parentElement = placeholder.parentElement ?? docFrag
parentElement.replaceChild(elements, placeholder)
}
elementsMap.clear()
}
function html(staticText: TemplateStringsArray, ...values: any[]) {
const resourceMaps: ResourceMaps = {
elementsMap: new Map()
}
const fullText = staticText.reduce((acc, text, index) => {
const currentValue = values[index]
const stringValue = resolveValue(currentValue, index, resourceMaps)
return acc + text + stringValue
}, '')
const template = document.createElement('template')
template.innerHTML = fullText
const documentFragment = template.content
if (resourceMaps.elementsMap.size) {
placeElements(documentFragment, resourceMaps.elementsMap)
}
return documentFragment
}
By doing this you’ll notice that everything returns to work just fine. You might be thinking, “All this to work as before?”, and yes, but by doing it we can add more features that were not possible before. With that in mind, lets add support for array values.
interface ResourceMaps {
elementsMap: Map<string, DocumentFragment | unknown[]>
}
function resolveValue(value: unknown, index: number, resourceMaps: ResourceMaps) {
if (value instanceof DocumentFragment || Array.isArray(value)) {
const elementId = `el="el-${index}"`
resourceMaps.elementsMap.set(elementId, value)
return `<template ${elementId}></template>`
}
return getCorrectStringValue(value)
}
function placeElements(docFrag: DocumentFragment, elementsMap: ResourceMaps['elementsMap']) {
for (const [elementId, elements] of elementsMap) {
const placeholder = docFrag.querySelector(`[${elementId}]`)
if (!placeholder) continue
const parentElement = placeholder.parentElement ?? docFrag
if (elements instanceof DocumentFragment) {
parentElement.replaceChild(elements, placeholder)
continue
}
for (const elementOrData of elements) {
const element = !(elementOrData instanceof Node)
? new Text(String(elementOrData ?? ''))
: elementOrData
parentElement.insertBefore(element, placeholder)
}
placeholder.remove()
}
elementsMap.clear()
}
With a simple addition in placeElements function and the if statement in resolveValue we add support for array values. A little observation in this part, notice that is a verification if the value in the array is instance of Node
and if not we create a TextNode
with the string of that value, this method also prevent XSS attacks by making every string value a text node, even if it is valid HTML text. If you prefer you can use the resolveValue for every value inside the array, I personally don’t think that is necessary, even because we’ll need to prevent a collision with that element ids. But it can be done in a simple way if you want to, take the index passed to the resolveValue function and multiply it by 10 and then add to the index of the array value you are working on, and when you place the elements you will need to loop through the array values and make the replacement.
Events
Attaching events is now possible since we’re using actual elements and we can attach them with a simple implementation very similar with the one used to place elements. We’ll add a new eventsMap in the resourceMaps, verify in the code if the value is a function, use regex to verify on the already parsed html text which event it is and set it on the eventsMap.
function resolveValue(
html: string,
value: unknown,
index: number,
resourceMaps: ResourceMaps
) {
if (value instanceof DocumentFragment || Array.isArray(value)) {
const elementId = `el="el-${index}"`
resourceMaps.elementsMap.set(elementId, value)
return `<template ${elementId}></template>`
}
if (typeof value === 'function') {
const match = html.match(eventRegex)
if (match) {
const eventName = match[1]
const eventId = `"evt-${index}"`
resourceMaps.eventsMap.set(`${eventName}=${eventId}`, value)
return eventId
}
}
return getCorrectStringValue(value)
}
function html(staticText: TemplateStringsArray, ...values: any[]) {
const resourceMaps: ResourceMaps = {
elementsMap: new Map(),
eventsMap: new Map()
}
const fullText = staticText.reduce((acc, text, index) => {
const currentValue = values[index]
const currentHtml = acc + text
const stringValue = resolveValue(currentHtml, currentValue, index, resourceMaps)
return currentHtml + stringValue
}, '')
const template = document.createElement('template')
template.innerHTML = fullText
const documentFragment = template.content
if (resourceMaps.elementsMap.size) {
placeElements(documentFragment, resourceMaps.elementsMap)
}
return documentFragment
}
An use example.
html`
<button on-click=${() => console.log('Hi')}>Click!</button>
`
// In the eventsMap will be set like this
eventsMap.set('click="evt-0"', () => console.log('Hi'))
// And in the html text the function value will be replaced for
'<button on-click="evt-0">Click</button>'
The prefix on-
is not mandatory, it is a prefix that I choose to use to check if the attribute text is about an event and this prefix can be anything you want, like a @
or :
.
Now we have an events map we can make a function similar to the placeElements but for the events.
function applyEvents(docFrag: DocumentFragment, eventsMap: ResourceMaps['eventsMap']) {
for(const [key, eventListener] of eventsMap) {
const element = docFrag.querySelector(`[on-${key}]`)
if (!element) continue
const [eventName] = key.split('=')
element.removeAttribute(`on-${eventName}`)
element.addEventListener(eventName, eventListener as EventListener)
}
eventsMap.clear()
}
function html(staticText: TemplateStringsArray, ...values: any[]) {
const resourceMaps: ResourceMaps = {
elementsMap: new Map(),
eventsMap: new Map()
}
const fullText = staticText.reduce((acc, text, index) => {
const currentValue = values[index]
const currentHtml = acc + text
const stringValue = resolveValue(currentHtml, currentValue, index, resourceMaps)
return currentHtml + stringValue
}, '')
const template = document.createElement('template')
template.innerHTML = fullText
const documentFragment = template.content
if (resourceMaps.elementsMap.size) {
placeElements(documentFragment, resourceMaps.elementsMap)
}
if (resourceMaps.eventsMap.size) {
applyEvents(documentFragment, resourceMaps.eventsMap)
}
return documentFragment
}
With only this you already can attach events to your elements, but if someone set the event attaching outside of the element.
html`
<button>
on-click=${() => console.log('hi')}
</button>
`
// With this the end html will look like this
'<button>on-click="evt-0"</button>'
In the end of the applyEvents function all functions set on the map are removed, so the text is going to appear in the final html but will be no event pending to be attached and this can be a sign to the developer that they put the listener in the wrong place. But you can implement some code to deal with it if you want.
And that is it for this part, you have seen that some features works just fine or better just by using actual elements in the return of the html tag function. I hope you liked the content, any questions leave a comment below and see you in the next post.
This content originally appeared on DEV Community 👩💻👨💻 and was authored by Gabriel José
Gabriel José | Sciencx (2023-02-07T20:32:02+00:00) Creating a HTML Tag Function – Part 2. Retrieved from https://www.scien.cx/2023/02/07/creating-a-html-tag-function-part-2/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.