Manipulating the generated HTML markup during data binding

I need some help regarding changing/manipulating some HTML markup that is generated by data binding.

Suppose I have a view that renders a list of search results. Something like this:

app.ts

export interface Contact {
  id: number;
  firstName: string;
  lastName: string;
  email?: string;
  phoneNumber?: string;
}

export class App {
  contacts: Contact[] = [
    { id: 1, firstName: "John", lastName: "Tolkien", email: "tolkien@inklings.com", phoneNumber: "867-5309" },
    { id: 2, firstName: "Clive", lastName: "Lewis", email: "lewis@inklings.com", phoneNumber: "867-5309" },
    { id: 3, firstName: "Owen", lastName: "Barfield", email: "barfield@inklings.com", phoneNumber: "867-5309" },
    { id: 4, firstName: "Charles", lastName: "Williams", email: "williams@inklings.com", phoneNumber: "867-5309" },
    { id: 5, firstName: "Roger", lastName: "Green", email: "green@inklings.com", phoneNumber: "867-5309" }
  ];
}

app.html

<template>
  <require from="./highlight-value-converter"></require>
  <require from="./highlight-custom-attribute"></require>

  <style>
    .item {
      position: relative;
      margin: .25rem;
      padding: .25em;
      border: 1px solid;
      border-radius: .25em;
    }

    .item-name {
      font-size: larger;
      font-weight: bolder;
    }

    .item-info-label {
      display: inline-block;
      width: 10em;
    }

    .item-info-text {
      font-weight: bolder;
    }
  </style>

  <div repeat.for="contact of contacts" class="item">
    <div class="item-name">${contact.firstName} ${contact.lastName}</div>
    <div>
      <span class="item-info-label">E-mail:</span>
      <span class="item-info-text">${contact.email}</span>
    </div>
    <div>
      <span class="item-info-label">Phone number:</span>
      <span class="item-info-text">${contact.phoneNumber}</span>
    </div>
  </div>
</template>

The final generated HTML will probably look somewhat like this:

<style>
  .item {
    position: relative;
    margin: .25rem;
    padding: .25em;
    border: 1px solid;
    border-radius: .25em;
  }

  .item-name {
    font-size: larger;
    font-weight: bolder;
  }

  .item-info-label {
    display: inline-block;
    width: 10em;
  }

  .item-info-text {
    font-weight: bolder;
  }
</style>

<div class="item">
  <div class="item-name">John Tolkien</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">tolkien@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Clive Lewis</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">lewis@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Owen Barfield</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">barfield@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Charles Williams</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">williams@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Roger Green</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">green@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>

Now suppose this list was the result for searching/filtering for the letter ‘e’… I would like to additionally highlight this in the search results by replacing e with <mark>e</mark>. The expected generated HTML should look something like this:

<style>
  .item {
    position: relative;
    margin: .25rem;
    padding: .25em;
    border: 1px solid;
    border-radius: .25em;
  }

  .item-name {
    font-size: larger;
    font-weight: bolder;
  }

  .item-info-label {
    display: inline-block;
    width: 10em;
  }

  .item-info-text {
    font-weight: bolder;
  }
</style>

<div class="item">
  <div class="item-name">John Tolki<mark>e</mark>n</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">tolki<mark>e</mark>n@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Cliv<mark>e</mark> L<mark>e</mark>wis</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">l<mark>e</mark>wis@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Ow<mark>e</mark>n Barfi<mark>e</mark>ld</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">barfi<mark>e</mark>ld@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Charl<mark>e</mark>s Williams</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">williams@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>
<div class="item">
  <div class="item-name">Rog<mark>e</mark>r Gr<mark>e</mark><mark>e</mark>n</div>
  <div>
    <span class="item-info-label">E-mail:</span>
    <span class="item-info-text">gr<mark>e</mark><mark>e</mark>n@inklings.com</span>
  </div>
  <div>
    <span class="item-info-label">Phone number:</span>
    <span class="item-info-text">867-5309</span>
  </div>
</div>

I have tried a value converter, but it does not change the markup; the replacements get HTML encoded afterwards, so instead of a highlighted ‘e’, it will literally show <mark>e</mark>.

I have also tried a custom attribute, but I do not get it to work. And if I would get it to work, I am not sure how I would have to tackle data binding refreshes due to changes in the viewmodel’s collection.

Is there a way to accomplish this kind of additional HTML rendering behavior in a relatively easy way? Are there any known general techniques or best practices to manipulate (or hook into) the data binding HTML rendering/generation behavior of Aurelia?

1 Like

If you are using Aurelia2 then there is a recent PR from @bigopon that does exactly this. That is if you return a HTMLElment from the value converter, it will be interpolated correctly.

If you are using Aurelia1, then you can use custom attribute to manipulate the DOM Elements.

1 Like

If you’re sure it’s sanitized and safe, could you bind using innerhtml?

<span class="item-info-text" innerhtml.bind="<your stuff here like: gr<mark>e</mark><mark>e</mark>n@inklings.com>"></span>

1 Like

Hi @Sayan751,

Thanks for your answer.

For now, I am using Aurelia v1, but I can’t wait to switch to v2. For the sake of stability of my production app, I will have to wait for the final release of v2, however; I cannot take the risk for migrating to v2 as long as it’s still in pre-alpha, alpha, and even beta state. So that will probably take a little while longer.

So it seems I was looking in the right direction when I was investigating a custom attribute. :slight_smile:

I have created a highlight-text custom attribute and got it to work. Well, at least it works partially. This is what I got so far:

highlight-text-custom-attribute.ts

import { autoinject } from 'aurelia-framework';

@autoinject
export class HighlightTextCustomAttribute {
  value!: string;

  private innerhtml = '';

  constructor(private element: Element) { }

  bind() {
    this.innerhtml = this.element.innerHTML;
    this.valueChanged(this.value);
  }

  unbind() {
    this.element.innerHTML = this.innerhtml;
  }

  valueChanged(value: string) {
    this.element.innerHTML = (value)
      ? highlightTextInHtml(value, this.innerhtml)
      : this.innerhtml;
  }
}

function highlightTextInHtml(text: string, html: string) {
  let escapedTextRegExp = new RegExp(text.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), 'gi');
  return html.replace(escapedTextRegExp, match => `<mark>${match}</mark>`);
}

I updated my app view to use this custom attribute on the elements containing the data from the collection.

For convenience, I also added a textbox to test the search/replace behavior for the highlighting. And I added a button that updates the first item in the collection to see if the rendered HTML will be refreshed properly when collection data changes afterwards.

This is the code of my new app view:

app.ts

export interface Contact {
  id: number;
  firstName: string;
  lastName: string;
  email?: string;
  phoneNumber?: string;
}

export class App {
  contacts: Contact[] = [
    { id: 1, firstName: "John", lastName: "Tolkien", email: "tolkien@inklings.com", phoneNumber: "867-5309" },
    { id: 2, firstName: "Clive", lastName: "Lewis", email: "lewis@inklings.com", phoneNumber: "867-5309" },
    { id: 3, firstName: "Owen", lastName: "Barfield", email: "barfield@inklings.com", phoneNumber: "867-5309" },
    { id: 4, firstName: "Charles", lastName: "Williams", email: "williams@inklings.com", phoneNumber: "867-5309" },
    { id: 5, firstName: "Roger", lastName: "Green", email: "green@inklings.com", phoneNumber: "867-5309" }
  ];

  textToHighlight = 'e';

  updateCollection() {
    this.contacts[0].firstName = 'Blah';
    this.contacts[0].lastName = 'Blah';
    this.contacts[0].email = 'blah@inklings.com';
  }
}

app.html

<template>
  <require from="./highlight-text-custom-attribute"></require>

  <style>
    body > div {
      position: relative;
      margin: .25rem;
    }

    .item {
      padding: .25em;
      border: 1px solid;
      border-radius: .25em;
    }

    .item-name {
      font-size: larger;
      font-weight: bolder;
    }

    .item-info-label {
      display: inline-block;
      width: 10em;
    }

    .item-info-text {
      font-weight: bolder;
    }
  </style>

  <div repeat.for="contact of contacts" class="item">
    <div class="item-name" highlight-text.bind="textToHighlight">${contact.firstName} ${contact.lastName}</div>
    <div>
      <span class="item-info-label">E-mail:</span>
      <span class="item-info-text">${contact.email}</span>
    </div>
    <div>
      <span class="item-info-label">Phone number:</span>
      <span class="item-info-text" highlight-text.bind="textToHighlight">${contact.phoneNumber}</span>
    </div>
  </div>

  <div>
    <label for="highlight-text-input">Highlight text:</label>
    <input type="text" id="highlight-text-input" value.bind="textToHighlight">
  </div>

  <div>
    <input type="button" value="Update collection" click.delegate="updateCollection()">
  </div>
</template>

With the above code, the highlighting works as desired. :sunglasses:

However, because of the text replacements in the elements’ innerHTML, it seems that the data bindings from the collection get broken. :thinking:

To illustrate this final problem, I did not add the highlight-text attribute on the element that contains the contact’s email address. Pressing the “Update collection” button updates the name and email of the first item in the collection, but the rendered list only reflects the updated email.

Do you happen to know if there is any solution for this issue?

2 Likes

Hello @dnkm,

Thanks for your answer.

Yes, perhaps it is possible to bind to innerhtml.

However, I guess it would require manipulating the data in the collection or creating a second collection that holds the transformed data with the highlighting markup. And yes, it would probably also require sanitizing the data in the collection.

So I consider this a somewhat “dirty” solution. :wink: But if I cannot get a fully functional custom attribute to work, I will certainly investigate binding to innerhtml in more detail.

1 Like

The reason the data-binding is breaking is when you replace the complete innerHTML, you are also removing the internally injected infra/markers that Aurelia uses to to track the binding targets.

One a second thought using custom attribute might not be the best approach in this case. Being solely reliant on the DOM elements, it might get increasingly difficult to track down the interpolation targets, removing and adding new elements to the parent element. For change handling you might need to end up observing DOM mutation and react on that (removing old nodes and adding new ones etc.). This is me naively speaking, there might be other more sophisticated approaches.

However, it might be much easier if you wrap the highlighting logic in a custom element. That way you can bind the whole text and the text to be highlighted to that custom element. The custom element can build the HTML string, and bind it to the view using innerhtml.bind (As already pointed out by @dnkm you need to sanitize the string before binding). However the advantage in this case would be that whenever either of the whole text or the highlight text changes, you can rebuild the string, and aurelia binding will take care of the rest.

1 Like

Yes, that seems to be a very good idea. Since it involves at least two binding variables (one for the content/innerhtml and one for the text to replace/highlight), a custom element might be a more logical choice.

A next step in the evolution of this piece of code is to make the wrapping HTML code customizable, so that it can be styled properly. That means even more members/variables which also might need to support data binding.

Thank you very much for your feedback. I will give this a try today.

2 Likes

I have been trying to create a custom element for the last two hours, but I cannot get it to work properly.

Since the custom element needs to respond to changes in both the innerhtml contents and the text to highlight, my first naive intention was to simply use <slot innerhtml.bind="html"></slot> inside the component view and using an @observable html!: string; property in the viewmodel. But it seemed that the <slot> element was rendered away by Aurelia, resulting in no effective functionality.

Next, I tried to rewrite the custom element so that it would be compatible with a HTML Web Component. I did so by marking the viewmodel with the @useShadowDOM attribute. This resulted in the <span> element to remain intact in the component’s shadow DOM, but data binding the innerhtmlattribute still still didn’t seem to work; during rendering, the viewmodel’s html property was always empty within the observable changed callbacks.

I also tried two other things:

  1. adding innerhtml.bind="html" to the view’s wrapping <template> element, and
  2. injecting the underlying DOM element in the component’s constructor and explicitly creating a data binding expression observer for element.innerHTML using the injected BindingEngine instance.

Both these attempts failed as well.

So for now, I will give up. I will put the custom element implementation on hold and I will try to pick it up at a more suitable moment in the near future. Perhaps I am missing just a minor detail, but curently I fail to see it.

The earlier implemented solution using a custom attribute did have some data binding flaws, but for my current purposes it seems to do the job well enough, so I will keep using the custom attribute for the time being.

1 Like

Here is a simple example of what I had in mind: Dumber Gist.
Does that work for you?

3 Likes

That’s an interesting piece of code!
:+1:

It uses some functionality of which I wasn’t aware it existed in Aurelia (like the <let> element, the @inlineView attribute, and the changeHandler property of the @bindable attribute’s options object). :slight_smile:

It’s also a nice example how just a few lines of code are needed for a relatively complex issue at hand. My own attempts were small as well, but not as small as yours. :slight_smile:

I will review your code in more detail tomorrow.

Thank you VERY much. :slight_smile:

2 Likes