Typeahead Mentions
Machine-coding brief for @mentions typeahead — caret detection, portal list, insert token, keyboard, and a11y.
- machine-coding
- interview
- react
- autocomplete
Problem statement
Build @mentions typeahead inside a text input or textarea: typing @ then a query shows user suggestions; choosing one inserts a mention token. Interviewers score caret/query extraction, list positioning, and keyboard selection — harder than a standalone autocomplete box.
Requirements
Must have
- Detect active
@queryat caret (not every@in the string blindly) - Show suggestion list when query active
- Filter users by name/username
- Keyboard: ↑/↓, Enter select, Escape close
- Insert mention and close list; caret after inserted text
- Click suggestion inserts
Should have
- Debounced async search
- Portal list near caret (or under control)
- Store rich value (
@[Name](id)) vs plain@Name— clarify
Nice to have
- Highlight tokens in a mirrored highlighter layer
- Multi-trigger (
@users,#tags) - contentEditable chip mentions
Planning (5 minutes out loud)
- Extract mention query from value + selectionStart
- Plain textarea first — chips are a sequel
- Insert replaces
@queryspan - MVP — sync filter list + insert; then async + caret coords
- IME — ignore updates while composing
Architecture
MentionTextarea
├── textarea
├── SuggestionList (listbox)
└── utils: getMentionState, insertMention
Types
type User = { id: string; name: string; username: string };
type MentionTextareaProps = {
value: string;
onChange: (value: string) => void;
users?: User[]; // sync mock
searchUsers?: (q: string, signal: AbortSignal) => Promise<User[]>;
};
Implementation sketch
Detect active mention
type MentionState = {
query: string;
start: number; // index of @
end: number; // caret
} | null;
export function getMentionState(
value: string,
caret: number
): MentionState {
const upto = value.slice(0, caret);
// last @ not preceded by word char, no whitespace between @ and caret
const m = /(?:^|[\s([{])@([\w.]*)$/.exec(upto);
if (!m) return null;
const query = m[1];
const start = caret - query.length - 1;
return { query, start, end: caret };
}
Insert
export function insertMention(
value: string,
state: NonNullable<MentionState>,
user: User
) {
const token = `@${user.username} `;
const next =
value.slice(0, state.start) + token + value.slice(state.end);
const caret = state.start + token.length;
return { next, caret };
}
Component core
function MentionTextarea({
value,
onChange,
users = [],
searchUsers,
}: MentionTextareaProps) {
const ref = useRef<HTMLTextAreaElement>(null);
const [mention, setMention] = useState<MentionState>(null);
const [items, setItems] = useState<User[]>([]);
const [active, setActive] = useState(0);
function syncMention(nextValue: string, caret: number) {
const state = getMentionState(nextValue, caret);
setMention(state);
if (!state) {
setItems([]);
return;
}
if (searchUsers) {
// debounce + abort in a hook — sketch:
searchUsers(state.query, new AbortController().signal).then(setItems);
} else {
const q = state.query.toLowerCase();
setItems(
users.filter(
(u) =>
u.username.toLowerCase().includes(q) ||
u.name.toLowerCase().includes(q)
)
);
}
setActive(0);
}
function applyUser(user: User) {
if (!mention || !ref.current) return;
const { next, caret } = insertMention(value, mention, user);
onChange(next);
setMention(null);
setItems([]);
requestAnimationFrame(() => {
ref.current!.selectionStart = ref.current!.selectionEnd = caret;
ref.current!.focus();
});
}
return (
<div className="mention">
<textarea
ref={ref}
value={value}
aria-label="Message"
aria-autocomplete="list"
aria-expanded={!!mention && items.length > 0}
onChange={(e) => {
onChange(e.target.value);
syncMention(e.target.value, e.target.selectionStart);
}}
onKeyUp={(e) =>
syncMention(e.currentTarget.value, e.currentTarget.selectionStart)
}
onKeyDown={(e) => {
if (!mention || items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((i) => Math.min(items.length - 1, i + 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(0, i - 1));
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
applyUser(items[active]);
}
if (e.key === "Escape") {
e.preventDefault();
setMention(null);
setItems([]);
}
}}
/>
{mention && items.length > 0 && (
<ul role="listbox" aria-label="Mention suggestions">
{items.map((u, i) => (
<li
key={u.id}
role="option"
aria-selected={i === active}
onMouseDown={(e) => {
e.preventDefault();
applyUser(u);
}}
>
{u.name} (@{u.username})
</li>
))}
</ul>
)}
</div>
);
}
Use onMouseDown preventDefault so textarea doesn’t blur before click.
Caret coordinates (portal position)
// Approximate: mirror div technique or textarea-caret-position libraries.
// Interview verbal: clone styles into a hidden mirror pre, measure span offset.
Accessibility essentials
- Textarea remains the primary control
- Listbox options named; active option highlighted
- Escape closes without submitting parent forms
- Don’t trap focus in the list — keep focus in textarea and use
aria-activedescendantideally
Performance notes
- Debounce async search 150–300ms; AbortController
- Cap suggestions (8–20)
- Mirror highlighter can be expensive — throttle
Footguns
- Matching
@inside emails — require boundary before@ - Insert at wrong index after controlled re-render
- Blur before click loses selection
- Enter submits outer form while selecting mention
- Not handling composition events for CJK IME
Interview out-loud answer
Mentions parse the text before the caret for an
@querytoken with a boundary rule. While active, a listbox filters users; keyboard moves active index and Enter inserts@usernamereplacing the query span. Focus stays in the textarea. Async search is debounced and aborted. Rich chips require a separate overlay model.