Ariakit
/

Textarea with inline Combobox

Rendering Combobox as a textarea element to create an accessible multiline textbox in React. Inserting specific characters triggers a popup with dynamic suggestions.

import * as Ariakit from "@ariakit/react";
import { matchSorter } from "match-sorter";
import * as React from "react";
import { getList, getValue } from "./list.ts";
import "./style.css";
import {
getAnchorRect,
getSearchValue,
getTrigger,
getTriggerOffset,
replaceValue,
} from "./utils.ts";
export default function Example() {
const ref = React.useRef<HTMLTextAreaElement>(null);
const [value, setValue] = React.useState("");
const [trigger, setTrigger] = React.useState<string | null>(null);
const [caretOffset, setCaretOffset] = React.useState<number | null>(null);
const combobox = Ariakit.useComboboxStore();
const searchValue = Ariakit.useStoreState(combobox, "value");
const deferredSearchValue = React.useDeferredValue(searchValue);
const matches = React.useMemo(() => {
return matchSorter(getList(trigger), deferredSearchValue, {
baseSort: (a, b) => (a.index < b.index ? -1 : 1),
}).slice(0, 10);
}, [trigger, deferredSearchValue]);
const hasMatches = !!matches.length;
React.useLayoutEffect(() => {
combobox.setOpen(hasMatches);
}, [combobox, hasMatches]);
React.useLayoutEffect(() => {
if (caretOffset != null) {
ref.current?.setSelectionRange(caretOffset, caretOffset);
}
}, [caretOffset]);
// Re-calculates the position of the combobox popover in case the changes on
// the textarea value have shifted the trigger character.
React.useEffect(combobox.render, [combobox, value]);
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
combobox.hide();
}
};
const onChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const trigger = getTrigger(event.target);
const searchValue = getSearchValue(event.target);
// If there's a trigger character, we'll show the combobox popover. This can
// be true both when the trigger character has just been typed and when
// content has been deleted (e.g., with backspace) and the character right
// before the caret is the trigger.
if (trigger) {
setTrigger(trigger);
combobox.show();
}
// There will be no trigger and no search value if the trigger character has
// just been deleted.
else if (!searchValue) {
setTrigger(null);
combobox.hide();
}
// Sets our textarea value.
setValue(event.target.value);
// Sets the combobox value that will be used to search in the list.
combobox.setValue(searchValue);
};
const onItemClick = (value: string) => () => {
const textarea = ref.current;
if (!textarea) return;
const offset = getTriggerOffset(textarea);
const displayValue = getValue(value, trigger);
if (!displayValue) return;
setTrigger(null);
setValue(replaceValue(offset, searchValue, displayValue));
const nextCaretOffset = offset + displayValue.length + 1;
setCaretOffset(nextCaretOffset);
};
return (
<div className="wrapper">
<label className="label">
Comment
store={combobox}
value={value}
// We'll overwrite how the combobox popover is shown, so we disable
// the default behaviors.
showOnClick={false}
showOnChange={false}
showOnKeyPress={false}
// To the combobox state, we'll only set the value after the trigger
// character (the search value), so we disable the default behavior.
className="combobox"
render={
<textarea
ref={ref}
rows={5}
placeholder="Type @, # or :"
// We need to re-calculate the position of the combobox popover
// when the textarea contents are scrolled.
onScroll={combobox.render}
// Hide the combobox popover whenever the selection changes.
onPointerDown={combobox.hide}
onChange={onChange}
onKeyDown={onKeyDown}
/>
}
/>
</label>
store={combobox}
hidden={!hasMatches}
getAnchorRect={() => {
const textarea = ref.current;
if (!textarea) return null;
return getAnchorRect(textarea);
}}
className="popover"
>
{matches.map((value) => (
key={value}
value={value}
onClick={onItemClick(value)}
className="combobox-item"
>
<span>{value}</span>
))}
</div>
);
}

Stay tuned

Join 1,000+ subscribers and receive monthly tips & updates on new Ariakit content.

No spam. Unsubscribe anytime. Read latest issue