The Complexities of Implementing Inline Autocomplete for Content Editables
When we recently rebuilt our MailMerge feature (announcement post) we ran into an interesting challenge to implement an inline autocomplete. Our MailMerge feature lets users write a templated email and merge that with their Streak CRM data. That is, users can send emails to multiple recipients and Streak will customize each one based on the variables they insert into the template.
For better variable discovery and easier variable insertion we decided to go with an inline autocomplete like you see on popular services such as Twitter, Facebook, Slack or Github.
Since this inline autocomplete behavior exists in many other services we figured there would be a wealth of popular libraries to choose from. For our purposes we had two main requirements (more details on each found below):
- Uses native DOM events
- Allows us to use our existing menu components
Uses Native DOM Events
Native DOM events allow us to use the same library when enhancing Gmail’s compose body (a standard contenteditable) and within Streak’s own React-based input components. This has multiple benefits:
- Keeps code size small
- Easier to maintain when we want to update behavior
- Provides a consistent experience for our users. There can be subtle quirks around arrow handling and escape handling that can differ between libraries
Use our existing menu components
As part of the autocomplete UI there is a menu that pops up with suggestions. Streak already has a plethora of menus in its UI that arebuilt with our fantastic React Menu List library. For the re-use benefits listed above we wanted the autocomplete library to be able to decouple the menu rendering from its other responsibilities.
The Search for an Existing Solution
Pretty much every library we found relied on something other than the native DOM (jQuery, React or Bootstrap) except for one, TributeJS. If you’re ok with a fully React solution then React Mentions and React Githubish Mentions both look good.
Tribute has a nice API where you can attach a tribute instance to an existing input to magically add the mention capabilities and it uses native DOM events! Sweet!
However, Tribute doesn’t support the 2nd requirement of being able to use our own components as it handles rendering the menu and menu contents entirely itself. It does offer a “menuItemTemplate” option but that expects a string in return which you then have to be careful with your escaping so you don’t open yourself up to XSS attacks. Also, our in-house React MenuItem components expect to be housed in our Menu container component so using ReactDOM.renderToStaticMarkup() doesn’t work.
At this point we figured it would be better to try to enhance the existing Tribute library rather than go off and write our own. However, after we started digging into the source we found 2 things that moved us to build an in-house solution:
- There were some serious bugs (memory leak) that we couldn’t figure out how to fix, and
- Implementing an in-house solution with ideas from Tributejs would allow us to leverage a lot of our existing libraries keeping code much smaller
After digging around the Tribute source we found that powering an @mention in an input comes down to 5 different tasks.
- Monitor the cursor position
- Get the text around the cursor
- Check for potential matches
- If there are matches then render something at the cursor position
- If the user picks an autocomplete item then replace the typed in text with the replacement text
Monitor the cursor position
You need to listen to the different events that can change the cursor position: ‘keyup’, ‘input’, ‘click’, and ‘focus’. We use Kefir so listening to, merging, and debouncing all those events is straightforward.
Get the Text Around the Cursor
We need the text around the cursor so we can construct a query to check for potential matches. To do this we get the text node that the cursor is currently located in, get the parent, then call normalize (MDN docs) on the parent. We call normalize because the parent node can contain multiple text nodes that are siblings to one another. To the user all the text looks contiguous, but at the DOM level if we want to get the text that is before/after the cursor we need to manually join the content from these sibling text nodes. By calling normalize on the parent all the text nodes get collapsed into one and we don’t need to deal with any of that. After normalizing we get the text node again (since it could have changed after normalize) and some other convenient data like the character position of the cursor and the text before and after the cursor.
Check for Potential Matches
Once you have your cursor position and the text around your cursor what do you use as the query when triggering the autocomplete search? we made the choice that we would only consider the text within the same text node as the cursor. This means that new lines, phrases with different formatting (a bolded section of the text) would act as natural boundary markers.
After that we chose a straightforward algorithm that looks back from your cursor position one character at a time, with each iteration checking if there are any matches. You stop iterating as soon as you have some matches or you exhaust all the characters.
Render Something at the Cursor Position
Once we have the set of results we have to translate the current cursor position into pixel coordinates in order to render the menu. Tributejs solved this by inserting a marker element at the cursor position, using getBoundingClientRect() to get the marker position, and then removing the marker element (see their implementation).
I ended up implementing a similar technique, however we used another great library we have, contain-by-screen, that takes a fixed position element and positions it relative to an anchor element, including handling all the annoying edge cases (pun intended).
You may have noticed that the last line of the gist we call normalize on textNodeParent. We do this because inserting the marker element splits the text node into two text nodes, so calling normalize repairs this split.
Replace Query Text On Selection
Once the user selects one of the suggestions we then need to replace the text the user has typed with the replacement text. The first two lines of the function are doing the actual replacement, with the rest of the function placing the cursor just after the replacement text.
This solution meets both of our main requirements and even allows us to use our other existing libraries like containByScreen and Kefir. The autocomplete specific code is only around 100 lines which you can easily incorporate into your own projects no matter what technology you use.
If you like working on these kind of problems then check out our careers page as we’re always hiring great people!
Thanks to Sean McBride, Aleem Mawani, Fred Wulff, Becca Saines, Henry Walton, Erik Munson and John Wells for reading and editing drafts of this post.