DEV Community

Agustin Navcevich
Agustin Navcevich

Posted on • Originally published at Medium on

2 1

Vue: TextArea component with custom Spell-Check support

Recently I worked on a project where implementing a custom-made spell checker emulating the spell checker used by Gmail was a necessity.

As I work in a product company, I wanted to use a Vue component that did not use third-party libraries. So I created a custom component from scratch and in this article I explain a quick overview of the process of creating it.

Hands on

I’m going to explain this process by showing the bulding blocks that make the component possible. The component will have all the functionalities that an input has such as a label, a placeholder and one more functionality that’s the possibility to add custom spell cheking.

<template>
<div class="input--field">
<p class="input--field-label">
{{ label }}
</p>
<template v-if="!hasFocus && value === ''">
<p class="placeholder">{{ placeholder }}</p>
</template>
<div>
{{value}}
</div>
</div>
</template>
<script>
export default {
name: 'BaseTextareaSpelling',
props: {
value: {
type: [String, Number],
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
},
data() {
return {};
},
};
</script>

So, this is our component skeleton. From here I started working to create the component I wanted. Now let’s start looking at the parts that needed to be built to get the input with corrections.

— The word with suggestions element —

One of the basic parts of our component is the element that contains those words that need to be underlined since they have a correction.

To implement this component, a separate component was built. The functionality of this component is to receive the text and the corrections and paint the word so that it can later be corrected. Therefore, the entry of our component is going to be a word and a list of suggestions.

<template>
<span class="suggestions--block">
<ul v-if="showSuggestions" class="suggestions" contentEditable="false">
<li class="suggestions--close" @click="handleClose">
<icon-close />
</li>
<li v-for="suggestion in suggestions"
:key="suggestion.value">
<p @click="handleSelectSuggestion(word, suggestion.value)">
{{ suggestion.value }}
</p>
</li>
</ul>
<span class="error--spelling"
@click="openSuggestion"
@contextmenu.prevent="openSuggestion">
{{ word }}
</span>
</span>
</template>
<script>
export default {
name: 'BaseSuggestionWord',
props: {
word: {
type: String,
required: true,
},
suggestions: {
type: [Array, Object],
required: true,
},
},
data() {
return {
showSuggestions: false,
};
},
methods: {
handleClose() {
this.showSuggestions = false;
},
openSuggestion() {
this.showSuggestions = true;
},
handleSelectSuggestion(word, suggestion) {
this.$emit('select', { word, suggestion });
},
},
};
</script>

This component has two different part. The first one is the highlighted word, for this a span was created to hightlight the word. And the other one is the list of suggestions that will pop up when clicking the word. For this to happen, two actions were binded to the word. The right click and left click event with the click and contextmenu. And within these actions the flag that makes the suggestions visible is put in true. The other function we have is to select the word to correct it, this will be addressed later within the parent component, for now we just say that we have a function that emits the word with the suggestion to correct

Now that baseSpellingWord component it’s built, let’s continue to build our parent component. For the component to behave as an input we have to make our component reactive. Before achieving this, the div component must be editable so it can be written inside of it. Enableling the contentEditable propert allows this, and setting the spell check porperty to false makes the browser not to make spelling corrections within this component.

Making a editable content component reactive has some gotchas. But let’s explain how to do it the easy way. First of all, a reference is added to the component to call it from other parts of the component. Also the the listeners are bindend with the v-on directive, adding a custom function for the onInputaction. Here the value that’s inisde our content editable component is emited.

<template>
<div class="input--field">
<p class="input--field-label" :class="{ active: isActive }">
{{ label }}
</p>
<template v-if="!hasFocus && value === ''">
<p class="placeholder">{{ placeholder }}</p>
</template>
<div
ref="editable"
class="box--spelling spelling--textarea"
contentEditable="true"
spellcheck="false"
@focus="hasFocus = true"
@blur="hasFocus = false"
v-on="listeners">
</div>
</div>
</template>
<script>
export default {
name: 'BaseTextareaSpelling',
props: {
value: {
type: [String, Number],
default: '',
},
suggestions: {
type: [Array, Object],
default() {
return [];
},
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
},
data() {
return {
hasFocus: false,
};
},
computed: {
listeners() {
return { ...this.$listeners, input: this.onInput };
},
isActive() {
return ((this.value === 0 || !!this.value) || this.hasFocus || this.placeholder);
},
},
mounted() {
this.$refs.editable.innerText = this.value;
},
methods: {
onInput(e) {
const text = parser.parseHtmlToText(e.target);
this.$emit('input', text);
},
},
};
</script>

Now the component is reactive. If you pay attention I’ve a function called parseHTMLtoText was added to the component. This functions serves to remove all elements within our component and get the clean input.

function parseHtmlToText(html) {
const element = html.cloneNode(true);
const spans = element.getElementsByClassName('suggestions');
while (spans.length > 0) {
spans[0].parentNode.removeChild(spans[0]);
}
let text = element.textContent.split('\n').join('');
text = text.trimStart();
text = text.replace(/ +/g, ' ');
return text;
}
view raw parseToHTML.js hosted with ❤ by GitHub

Once we have the reactive component, the last step that remains is to add the text with the corrections and have it coexist with the text that has no corrections.

<template>
<div class="input--field">
<p class="input--field-label box--spelling-label" :class="{ active: isActive }">
{{ label }}
<span v-if="obligatory" class="text--color-red">*</span>
</p>
<template v-if="!hasFocus && value === ''">
<p class="placeholder">{{ placeholder }}</p>
</template>
<div
ref="editable"
class="box--spelling spelling--input"
:contentEditable="true"
spellcheck="false"
@focus="hasFocus = true"
@blur="handleBlur"
v-on="listeners"
>
<template v-for="suggestion in textWithCorrections">
{{ suggestion.suggestions === undefined ? suggestion.phrase : "" }}
<base-suggestion-word v-if="suggestion.suggestions !== undefined"
:key="suggestion.start"
:suggestions="suggestion.suggestions"
:word="suggestion.phrase"
/>
</template>
</div>
</div>
</template>
<script>
import parser from '@/utils/spellParser';
export default {
name: 'BaseInputSpelling',
props: {
value: {
type: [String, Number],
default: '',
},
suggestions: {
type: [Array, Object],
default() {
return [];
},
},
label: {
type: String,
default: '',
},
obligatory: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
},
data() {
return {
hasFocus: false,
textWithCorrections: [],
};
},
computed: {
listeners() {
return { ...this.$listeners, input: this.onInput };
},
isActive() {
return ((this.value === 0 || !!this.value) || this.hasFocus || this.placeholder);
},
canEdit() {
return !(this.disabled || this.readonly);
},
},
watch: {
suggestions(newSuggestions) {
const element = this.$el.getElementsByClassName('spelling--input');
element[0].textContent = '';
this.textWithCorrections = parser.prepareWordListWithSuggestions(this.value, newSuggestions);
},
},
mounted() {
this.$refs.editable.innerText = this.value;
this.textWithCorrections = [];
},
methods: {
onInput(e) {
const element = e.target;
const text = parser.parseHtmlToText(element);
this.$emit('input', text);
},
handleChangeWord({ word, suggestion }) {
this.textWithCorrections = parser.removeFromListWithSuggestions(
this.textWithCorrections, word, suggestion,
);
setTimeout(() => {
const element = this.$el.getElementsByClassName('spelling--input');
const text = parser.parseHtmlToText(element[0]);
this.$emit('input', text);
}, 100);
},
},
};
</script>

A new entity was created for these two worlds to coexist: textWithCorrections This entity is an array of objects where each object has a property with the original phrase and if it has suggestions it has a property with all the suggestions.

function prepareWordListWithSuggestions(phrase = '', suggestions = []) {
const result = [];
let phraseAux = phrase;
let phrasePointer = 0;
suggestions.forEach((suggestion) => {
const start = suggestion.start - phrasePointer;
const end = suggestion.offset - phrasePointer;
const firstPart = phraseAux.slice(0, start);
if (firstPart !== '') {
result.push({ phrase: firstPart });
}
result.push({
phrase: phraseAux.substr(start, suggestion.offset),
suggestions: suggestion.recommends,
});
phraseAux = phraseAux.slice(end);
phrasePointer += end;
});
if (phraseAux !== '') {
result.push({ phrase: phraseAux });
}
return result;
}
function removeFromListWithSuggestions(listWithSuggestions = [], phrase = '', suggestion = '') {
const index = listWithSuggestions.findIndex(text => text.phrase === phrase);
const result = listWithSuggestions.slice();
if (index >= 0) {
result.splice(index, 1, { phrase: phrase });
}
return result;
}
view raw parser.js hosted with ❤ by GitHub

In order to work with this entity, two functions were created. One that takes care of updating the array every time a new suggestion arrives. To do this effectively we use the method of watchso that every time the suggestions change this method is called. The other function serves to remove the suggestions given a word and a suggestion. This is the function that is called from the component we created first for the words.

After this we have our component completed and ready to use. I hope you take with you some ideas on how to work with a component like this and how to use it on your applications.

Please share any thoughts or comments you have. Feel free to ask and correct me if I’ve made some mistakes.

Thanks for your time!

Image of Datadog

The Essential Toolkit for Front-end Developers

Take a user-centric approach to front-end monitoring that evolves alongside increasingly complex frameworks and single-page applications.

Get The Kit

Top comments (1)

Collapse
 
josiasmueller profile image
josiasmueller

Nice! Looking really pretty!

Could you maybe put a demo on jsfiddle.net/ or on an other plattform? ATM I'm struggling to get your code to run.

Thanks! <3