Slev7n
Slev7n

Reputation: 371

How to embed a DOM node inside a template literal?

Is there any way to embed a DOM element inside a template string?

const btn = document.createElement('button');
btn.addEventListener('click', () => {
  alert("hello");
});

document.body.innerHTML = `
  <div>${btn}</div>
`;

The above just calls HTMLElement's toString function and renders “[object HTMLButtonElement]” instead of the actual button.

Upvotes: 3

Views: 3928

Answers (4)

dumbass
dumbass

Reputation: 27211

Such an API, if it existed, would not be able to return a string. A string consists of characters, not of DOM nodes with their own identities. And since it wouldn’t be a string, you wouldn’t be able to use it with .innerHTML (which can be a pretty poor API for interacting with the DOM for reasons I will not delve into here). That said, if there were a tag function taking HTML templates, there is something it could construct instead – a DocumentFragment. While you can’t assign one to .innerHTML, it’s pretty convenient to use with node-based DOM APIs like .appendChild, .insertBefore or .replaceChildren. But as of 2024, there is no such tag function built in – you will have to create your own.

The biggest obstacle is parsing HTML piecemeal. DOM APIs do not offer an interface that allows feeding incomplete pieces of HTML code into the parser and modify the DOM in the middle of parsing – most parsing APIs are completely black-box, and document.write is pretty inadequate too. Parsing the HTML pieces between interpolation points yourself is also a non-starter – unlike XML which is merely annoying to parse, HTML is fiendishly complex to parse. So the best you can do is to first insert placeholders at interpolation points, parse the resulting string, then look for the placeholders in the constructed DOM tree and replace them with actual values.

Below is a very crude proof of concept of how the latter might be done:

const html = (pieces, ...values) => {
  const frag = document.createRange().createContextualFragment(
    String.raw(
      { raw: pieces.map(piece => piece.replaceAll('\x91', '\x91\x92')) },
      ...values.map((_, i) => `\x91${i}\x92`)));
  const walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT);

  while (walker.nextNode()) {
    let node = walker.currentNode;
    const parent = node.parentNode;
    let m;
    
    while (m = /\x91(\d*)\x92/u.exec(node.data)) {
      const ipoint = node.splitText(m.index);
      node = ipoint.splitText(m[0].length);
      let val = m[1] !== '' ? values[m[1]] : '\x91';
      if (typeof val === 'string')
        val = document.createTextNode(val);
      parent.replaceChild(val, ipoint);
    }
  }

  return frag;
};

// example usage

const button = document.createElement('button')
button.appendChild(html`<em>Please</em> click me, ${"Bobby <table>s"}!`);
button.onclick = (ev) => {
  alert("hello!");
};

document.body.appendChild(html`
  <p> Press this button: ${button} – or else.
`);

// example with a non-element node

const clockNode = document.createTextNode("");
const updateClock = () => {
  const d = new Date();
  clockNode.data =
    `${d.getHours()}` + ":" +
    `${d.getMinutes()}`.padStart(2, '0') + ":" +
    `${d.getSeconds()}`.padStart(2, '0');
};
setInterval(updateClock, 1000);
updateClock();

document.body.appendChild(html`
  <p> The current time is ${clockNode}. Try selecting this paragraph!
`);

The above is pretty incomplete – putting an interpolation point in places other than within element bodies will yield incorrect results without so much as an error thrown:

// this leaves un-substituted placeholders in place
document.body.appendChild(html`
  <p> Visit <a href="${'https://example.com/'}">our website</a>.

  <p> Visit <a ${Object.assign(
    document.createAttribute('href'),
    { value: 'https://example.com/' })}>our website</a>.

  <!-- ${"will this work?"} -->
`);

This may be acceptable if you don’t ever need to interpolate data into those places, but it could have been addressed as well, even with the kludgy placeholder technique used here, by scanning DOM nodes other than text nodes. For the sake of simplicity, the above demonstrative implementation does not bother.

At least you don’t need to worry about spurious placeholder markers – and in fact the characters U+0091 and U+0092 are very good delimiter choices for that reason. That is because the only way they can appear within the parsed DOM is because they appeared literally in the source code, as HTML5 does not allow escaping them. Per the specification, most numeric character references in the C1 range do not resolve to their corresponding Unicode code points and have to be instead interpreted as referring to windows-1252 code points (HTML LS 2024-11-28 §13.2.5.80 “Numeric character reference end state”). This would normally be a misfeature, but in this case it works to our advantage: it means that escaping the delimiters within ambient code fragments (i.e. within pieces) is just a matter of dumb substring replacement and does not require implementing a full HTML parser – you don’t also have to worry about things like html` ${"foo"} &#x91;0&#x92;`.

Upvotes: 1

KooiInc
KooiInc

Reputation: 122906

in template literals the replacement values (${...}) by default are converted to their string representation ([object HTMLButtonElement] in your case).

It may be an idea to create a tag function to convert HTML element replacements [in a template literal] with their outerHTML. This idea is worked out in the next snippet.

Btw: innerHTML is considered unsafe and slow. There are several alternatives to inject html into an existing element. The snippet uses insertAdjacentHTML combined with a tag function and event delegation to handle a button click:

document.addEventListener(`click`, handle);

// ↓ a tag function to convert html *nodes* within
//   a template string to HTML *strings*
function insertElements(strings, ...insertions) {
  return insertions.reduce( 
    (result, value, i) => 
      result.concat( strings[i], 
      value instanceof HTMLElement ? value.outerHTML : value ), 
    "" );
}

// ↓ create a span
const span = document.createElement(`span`);
span.append(`Give me a button please! `);

// ↓ create a button
const bttn = document.createElement(`button`);
bttn.classList.add(`doclickme`);
bttn.append(`Ok, I Am Button`);

// ↓ append the button to the span
span.append(bttn);

// ↓ append the span to document.body using 
//   the tag function icw insertAdjacentHTML
document.body.insertAdjacentHTML(
  `afterend`, 
  insertElements`<div>${span}}</div>`
);

// ↓ document wide handling
function handle(evt) {
  if ( evt.target.closest(`.doclickme`) ) {
    console.clear();
    return console.log(`Hi. I am indeed added dynamically`);
  }
}

See also ...

Upvotes: 0

ziggy wiggy
ziggy wiggy

Reputation: 1067

No, the element itself can not be inserted that way. You could serialize it to HTML, but you'll lose any updates you made to the element, such as the event handler.

Instead you could create the entire structure using HTML, then select the button to add the listener.

someDiv.innerHTML = `
<div><button>click me</button></div>
`;

someDiv.querySelector("button")
  .addEventListener('click', handler);

var pre = document.querySelector("pre");

function handler(e) {
  pre.textContent += "foo ";
}
<div id=someDiv>
</div>
<pre></pre>

Upvotes: 1

Maheer Ali
Maheer Ali

Reputation: 36574

You can use Element.outerHTML to get element as string. Note it will not append the real btn to div it will copy the element's html and eventListener attached to it will not work.

If you want the copy elements with its function you can use cloneNode() and appendChild() to insert it to parent.

let somediv = document.querySelector('#somediv');
const btn= document.createElement('button');
btn.innerHTML = 'Click me';
btn.addEventListener('click',(e) => {
  somediv.innerHTML = `<div>${btn.outerHTML}</div>`
  console.log(somediv.innerHTML);
})
document.body.appendChild(btn);
#somediv{
  position:absolute;
  top:200px;
  left:200px;
  padding:10px;
  background:blue;
}
<div id="somediv"></div>

Upvotes: 2

Related Questions