Em Ji Madhu
Em Ji Madhu

Reputation: 724

How can I dynamically wrap a substring with a component in Vue.js?

What am I trying to do

Given a string from a user input, I am attempting to render this string with particular substrings wrapped in a component. In my particular case the substring being matched is a date that matches a regex pattern and the component that is supposed to wrap it is a chip from Vuetify.

What I have done

Expected view

Above is a screenshot of what I have achieved so far. The input of the textarea is rendered below it with certain substrings wrapped in the chip component from Vuetify. The above was achieved by replacing the substring matched by a regex pattern with the HTML to render the component and giving this string to a v-html directive for rendering. Below is some code showing how this was done.

<div style="line-height:40px;" v-html="messageOutput"></div>
let finalStr = ''
let str = 'Your a/c no. XXXXXXXXXX85 is credited on 15-11-17.'

let dateReg = /((?=\d{4})\d{4}|(?=[a-zA-Z]{3})[a-zA-Z]{3}|\d{2})((?=\/)\/|-)((?=[0-9]{2})[0-9]{2}|(?=[0-9]{1,2})[0-9]{1,2}|[a-zA-Z]{3})((?=\/)\/|-)((?=[0-9]{4})[0-9]{4}|(?=[0-9]{2})[0-9]{2}|[a-zA-Z]{3})/

const date = dateReg.exec(str)
finalStr = str.replace(date[0], `
 <div class="md-chip md-chip-clickable">
  <div class="md-chip-icon primary"><i class="material-icons">date_range</i></div>
   ${date[0]}
 </div>
`)

What is not working

The problem is using custom components as opposed to plain HTML does not give the expected output. The styling is not rendered and the component does not react to events.

How can I dynamically wrap a substring with a component in Vue.js?

Upvotes: 3

Views: 4952

Answers (1)

Wing
Wing

Reputation: 9691

Problem

The problem of custom components not working as expected stems from the attempt of including them inside a v-html directive. Due to the value of the v-html directive being inserted as plain HTML, by setting an element's innerHTML, data and events are not reactively bound.

Note that you cannot use v-html to compose template partials, because Vue is not a string-based templating engine. Instead, components are preferred as the fundamental unit for UI reuse and composition.

Vue guide on interpolating raw HTML

[v-html updates] the element’s innerHTML. Note that the contents are inserted as plain HTML - they will not be compiled as Vue templates. If you find yourself trying to compose templates using v-html, try to rethink the solution by using components instead.

Vue API documentation on the v-html directive

Solution

Components are the fundamental units for UI reuse and composition. We now must build a component that is capable of identifying particular substrings and wrapping a component around them. Vue's components/templates and directives by themselves would not be able to handle this task – it just isn't possible. However Vue does provide a way of building components at a lower level through render functions.

With a render function we can accept a string as a prop, tokenize it and build out a view with matching substrings wrapped in a component. Below is a naive implementation of such a solution:

const Chip = {
  template: `
    <div class="chip">
      <slot></slot>
    </div>
  `,
};

const SmartRenderer = {
  props: [
    'string',
  ],

  render(createElement) {
    const TOKEN_DELIMITER_REGEX = /(\s+)/;
    const tokens = this.string.split(TOKEN_DELIMITER_REGEX);
    const children = tokens.reduce((acc, token) => {
      if (token === 'foo') return [...acc, createElement(Chip, token)];
      return [...acc, token];
    }, []);

    return createElement('div', children);
  },
};

const SmartInput = {
  components: {
    SmartRenderer
  },

  data: () => ({
    value: '',
  }),

  template: `
    <div class="smart-input">
      <textarea
        class="input"
        v-model="value"
      >
      </textarea>
      <SmartRenderer :string="value" />
    </div>
  `,
};

new Vue({
  el: '#root',

  components: {
    SmartInput,
  },

  template: `
    <SmartInput />
  `,

  data: () => ({}),
});
.chip {
  display: inline-block;
  font-weight: bold;
}

.smart-input .input {
  font: inherit;
  resize: vertical;
}

.smart-input .output {
  overflow-wrap: break-word;
  word-break: break-all;
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/milligram.css">
</head>
<body>
  <p>Start typing below. At some point include <strong>foo</strong>, separated from other words with at least one whitespace character.</p>
  <div id="root"></div>
  <script src="https://unpkg.com/[email protected]/dist/vue.min.js"></script>
</body>
</html>

There is a SmartRenderer component which accepts a string through a prop. Within the render function we:

  1. Tokenize the string by splitting it by whitespace characters.
  2. Build up an array of elements to be rendered by iterating through each token and
  3. Checking if the token matches a rule (in our naive implementation seeing if the string matches foo) and wrapping it in a component (in our naive implementation the component is a Chip, which just makes the foo bold) otherwise leave the token as is.
  4. Accumulating the result of each iteration in an array.
  5. The array of Step 3 is then passed to createElement as the children of a div element to be created.
render(createElement) {
  const TOKEN_DELIMITER_REGEX = /(\s+)/;
  const tokens = this.string.split(TOKEN_DELIMITER_REGEX);
  const children = tokens.reduce((acc, token) => {
    if (token === 'foo') return [...acc, createElement(Chip, token)];
    return [...acc, token];
  }, []);

  return createElement('div', children);
},

createElement takes an HTML tag name, component options (or a function) as its first argument and in our case the second argument takes a child or children to render. You can read more about createElement in the docs.

The solution as posted has some unsolved problems such as:

  • Handling a variety of whitespace characters, such as newlines (\n).
  • Handling multiple occurrences of whitespace characters, such as (\s\s\s).

It is also naive in the way it checks if a token needs to be wrapped and how it wraps it – it's just an if statement with the wrapping component hard-coded in. You could implement a prop called rules which is an array of objects specifying a rule to test and a component to wrap the token in if the test passes. However this solution should be enough to get you started.

Further reading

Upvotes: 6

Related Questions