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:
- Tokenize the string by splitting it by whitespace characters.
- Build up an array of elements to be rendered by iterating through each token and
- 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.
- Accumulating the result of each iteration in an array.
- 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