Engineering

@mention menus without losing focus

An @mention popup should never take focus from the editor. Read the query from the document, claim only the keys the menu needs, and respect input composition.

6 min read

An @mention menu is a small popup that opens when the writer types a trigger character, offers a list of names, and inserts the chosen one. It fights the editor for the keyboard: the popup wants the arrow keys and Enter, the editor wants them for the caret, and the writer just wants the name to appear. The rule that makes it work is that the editor keeps focus the whole time.

Never move focus into the popup

The most common mistake is a search box inside the popup that takes focus. The writer then types into the search box instead of the document, and the text never reaches the editor. The menu should be a list that reacts to the keys the editor passes to it, while the caret stays in the document.

Mark the popup with role="listbox" and each option with role="option" and aria-selected. Link the editor to the highlighted option with aria-activedescendant. A screen reader then announces the highlighted name as the writer moves through the list, and focus never leaves the document.

Read the query from the document, not from the popup

Once the trigger is typed, the query is the text between that character and the caret. Read it from the document every time the text changes. A separate query string in the popup drifts out of step as soon as the writer uses the arrow keys, backspace or paste.

Code example
function mentionQuery(textBefore: string): string | null {
  // Only treat @ as a trigger at the start of a word, so an address such as
  // name@example.com does not open the menu.
  const match = /(?:^|s)@([w.-]{0,40})$/.exec(textBefore);
  return match ? match[1] : null;
}

mentionQuery("Thanks @ana");        // "ana"
mentionQuery("write to name@host"); // null

The whitespace rule decides when the menu opens. Requiring a space or the start of a line before the trigger keeps the menu out of email addresses and URLs. Capping the query length stops a stray character far back in the text from keeping the menu open.

Claim only the keys the menu needs

While the menu is open, the arrow keys, Enter and Tab belong to it, and Escape closes it. Every other key goes to the editor so typing keeps working. Call preventDefault() only for the keys the menu uses. A menu that swallows a letter the writer needs, or blocks Enter when it has no matches, is the bug writers notice first.

Enter needs its own rule. If the menu has no matches, Enter should act as Enter in the document, creating a new paragraph, and the menu should close. Otherwise a writer who typed @ by mistake is stuck.

Composition and mentions do not mix well

Input methods for Chinese, Japanese and Korean send a composition sequence before the final text is committed. During composition, Enter confirms a character rather than choosing a mention. Ignore the menu keys while event.isComposing is true, and update the query only after composition ends. Some Android keyboards do not send reliable key codes at all, so read the query from the document as described above, not from individual key presses.

Insert a mention as a node, not as plain text

When the writer chooses a name, insert a mention node that stores the user’s identifier, and render the visible name from it. Plain text would lose the link to the account, and a rename would leave old mentions wrong. Replace the trigger and the query with the node in one transaction, so undo removes the mention and restores the text the writer typed.

A short test list


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.