Generic typeahead picker infrastructure for the Portable Text Editor
The useTypeaheadPicker hook provides the state and logic needed to build typeahead pickers (emoji pickers, mention pickers, slash commands, etc.) for the Portable Text Editor. It manages keyword matching, keyboard navigation, and triggering of actions, but is not concerned with the UI, how the picker is rendered, or how it's positioned in the document.
import {EditorProvider, PortableTextEditable} from '@portabletext/editor'
import {raise} from '@portabletext/editor/behaviors'
import {
defineTypeaheadPicker,
useTypeaheadPicker,
type AutoCompleteMatch,
} from '@portabletext/plugin-typeahead-picker'
// With `delimiter` configured, matches must include `type: 'exact' | 'partial'`
// for auto-completion to work. Use `AutoCompleteMatch` as the base type.
type EmojiMatch = AutoCompleteMatch & {
key: string
emoji: string
shortcode: string
}
const emojiPicker = defineTypeaheadPicker<EmojiMatch>({
// Trigger pattern - activates the picker when typed
trigger: /:/,
// Keyword pattern - matches characters after the trigger
keyword: /\S*/,
// Optional delimiter enables auto-completion.
// Typing `:joy:` will auto-insert if "joy" is an exact match.
delimiter: ':',
// Return matches for the keyword. Can be sync or async (with mode: 'async').
getMatches: ({keyword}) => searchEmojis(keyword),
// Action to execute when a match is selected (Enter/Tab or click).
// Receives the event containing the selected match and pattern selection.
onSelect: [
({event}) => [
raise({type: 'delete', at: event.patternSelection}), // Delete `:joy`
raise({type: 'insert.text', text: event.match.emoji}), // Insert 😂
],
],
})
function EmojiPickerPlugin() {
// Activate the picker and get its current state
const picker = useTypeaheadPicker(emojiPicker)
// Don't render anything when picker is inactive
if (picker.snapshot.matches('idle')) {
return null
}
const {keyword, matches, selectedIndex} = picker.snapshot.context
if (matches.length === 0) {
return <div>No emojis found for "{keyword}"</div>
}
return (
<ul>
{matches.map((match, index) => (
<li
key={match.key}
aria-selected={index === selectedIndex}
// Optional: enable mouse hover to select
onMouseEnter={() => picker.send({type: 'navigate to', index})}
// Optional: enable click to insert
onClick={() => picker.send({type: 'select'})}
>
{match.emoji} {match.shortcode}
</li>
))}
</ul>
)
}
// Render the picker inside EditorProvider, alongside PortableTextEditable
function MyEditor() {
return (
<EditorProvider /* ...config */>
<PortableTextEditable />
<EmojiPickerPlugin />
</EditorProvider>
)
}The picker component must be rendered inside EditorProvider to access the editor context. Position it as a sibling to PortableTextEditable - you'll handle the visual positioning (popover, dropdown, etc.) separately with CSS or a positioning library.
The picker activates when users type the trigger pattern (e.g., : or @). The keyword pattern then matches characters typed after the trigger.
- Keyboard shortcuts are built-in:
EnterorTabinserts the selected match↑/↓navigate through matchesEscdismisses the picker
- Mouse interactions are opt-in: Use
send({type: 'navigate to', index})andsend({type: 'select'})to enable hover and click - Auto-completion: With
delimiterconfigured, typing the delimiter after an exact match auto-inserts it (e.g.,:joy:auto-inserts the emoji)
const emojiPicker = defineTypeaheadPicker<EmojiMatch>({
trigger: /:/,
keyword: /\S*/,
delimiter: ':',
getMatches: ({keyword}) => searchEmojis(keyword),
onSelect: [
({event}) => [
raise({type: 'delete', at: event.patternSelection}),
raise({type: 'insert.text', text: event.match.emoji}),
],
],
}):joy: auto-inserts the emoji
// Without `delimiter`, the `type` field is not required on matches.
// MentionMatch can just be: { id: string; name: string }
const mentionPicker = defineTypeaheadPicker<MentionMatch>({
mode: 'async',
trigger: /@/,
keyword: /\w*/,
debounceMs: 200,
getMatches: async ({keyword}) => api.searchUsers(keyword),
onSelect: [
({event}) => [
raise({type: 'delete', at: event.patternSelection}),
raise({
type: 'insert.child',
child: {_type: 'mention', userId: event.match.id},
}),
],
],
})@john shows matches after 200ms pause, user selects with Enter/Tab
// Without `delimiter`, the `type` field is not required on matches.
const commandPicker = defineTypeaheadPicker<CommandMatch>({
trigger: /^\//, // ^ anchors to start of block
keyword: /\w*/,
getMatches: ({keyword}) => searchCommands(keyword),
onSelect: [
({event}) => {
switch (event.match.command) {
case 'h1':
case 'h2':
case 'h3':
return [
raise({type: 'delete', at: event.patternSelection}),
raise({type: 'style.toggle', style: event.match.command}),
]
case 'image':
return [
raise({type: 'delete', at: event.patternSelection}),
raise({type: 'insert.block', block: {_type: 'image'}}),
]
default:
return [raise({type: 'delete', at: event.patternSelection})]
}
},
],
})/heading shows matching commands, but only when / is at the start of a block. Text like hello /heading will NOT trigger the picker.
Use guard to conditionally prevent the picker from activating. The guard runs at trigger time (when the trigger character is typed) and has the same signature as a behavior guard, receiving snapshot, event, and dom.
const emojiPicker = defineTypeaheadPicker<EmojiMatch>({
trigger: /:/,
keyword: /\S*/,
delimiter: ':',
getMatches: ({keyword}) => searchEmojis(keyword),
// Guard runs when `:` is typed - return false to block activation
guard: ({snapshot, event, dom}) => {
// Don't activate if another UI element is open
if (isDialogOpen()) {
return false
}
return true
},
onSelect: [
({event}) => [
raise({type: 'delete', at: event.patternSelection}),
raise({type: 'insert.text', text: event.match.emoji}),
],
],
})The guard is useful for:
- Avoiding conflicts when another picker or dialog is already open
- Checking editor state or mode before allowing the picker
Creates a picker definition to pass to useTypeaheadPicker.
Config:
| Property | Type | Description |
|---|---|---|
trigger |
RegExp |
Pattern that activates the picker. Can include ^ for start-of-block triggers. Must be single-character (e.g., /:/, /@/, /^\//). |
keyword |
RegExp |
Pattern matching characters after the trigger (e.g., /\S*/, /\w*/). |
delimiter |
string? |
Optional delimiter that triggers auto-completion (e.g., ':' for :joy:) |
guard |
TypeaheadTriggerGuard? |
Optional guard that runs at trigger time to conditionally prevent activation |
mode |
'sync' | 'async' |
Whether getMatches returns synchronously or a Promise (default: 'sync') |
debounceMs |
number? |
Delay in ms before calling getMatches. Useful for both async (API calls) and sync (expensive local search) modes. (default: 0) |
getMatches |
(ctx: {keyword: string}) => TMatch[] |
Function that returns matches for the keyword |
onSelect |
TypeaheadSelectActionSet[] |
Action sets to execute when a match is selected |
onDismiss |
TypeaheadDismissActionSet[]? |
Optional action sets to execute when the picker is dismissed |
Trigger pattern rules:
- Must be a single-character trigger (e.g.,
:,@,/) - Multi-character triggers (e.g.,
##) are not supported - Position anchors (
^) allow start-of-block constraints
How triggering works:
The picker activates the moment a trigger character is typed. After activation, the keyword is tracked via editor selection changes.
User types `:` → Trigger matches → Picker activates with keyword ""
User types `j` → Keyword updates to "j" (via selection tracking)
User types `o` → Keyword updates to "jo"
User types `y` → Keyword updates to "joy"
Trigger compatibility summary:
| Trigger | Example input | Works? | Why |
|---|---|---|---|
/:/ |
:joy |
✅ | Single-char trigger |
/@/ |
@john |
✅ | Single-char trigger |
/^\// |
/cmd |
✅ | Single-char with position anchor |
/##/ |
##tag |
❌ | Multi-char triggers unsupported |
delimiter requirements:
Single-character delimiters work regardless of whether the character is included in the keyword pattern. Multi-character delimiters are not supported.
| keyword | delimiter | Example | Works? | Why |
|---|---|---|---|---|
/\S*/ |
: |
:joy: |
✅ | \S matches :, keyword becomes joy |
/\w*/ |
: |
:joy: |
✅ | \w stops at :, keyword is joy |
/\w*/ |
## |
#tag## |
❌ | Multi-char delimiter not supported |
React hook that activates a picker and returns its state.
Returns:
| Property | Description |
|---|---|
snapshot.matches(state) |
Check picker state: 'idle', {active: 'loading'}, {active: 'no matches'}, {active: 'showing matches'} |
snapshot.context.keyword |
The current keyword |
snapshot.context.matches |
Array of matches from getMatches |
snapshot.context.selectedIndex |
Index of the currently selected match |
send(event) |
Dispatch events: {type: 'select'}, {type: 'dismiss'}, {type: 'navigate to', index} |
snapshot.context.error |
Error from getMatches if it threw/rejected, otherwise undefined |
When mode: 'async' is configured, the picker handles asynchronous getMatches functions with loading states and race condition protection.
Use snapshot.matches() to check nested loading states:
function MentionPicker() {
const picker = useTypeaheadPicker(mentionPicker)
// Initial loading (no results yet)
const isLoading = picker.snapshot.matches({active: 'loading'})
// Background refresh (showing stale results while fetching new ones)
const isRefreshing = picker.snapshot.matches({
active: {'showing matches': 'loading'},
})
// No matches, but still fetching (to avoid flicker)
const isLoadingNoMatches = picker.snapshot.matches({
active: {'no matches': 'loading'},
})
if (isLoading) return <Spinner />
if (picker.snapshot.matches({active: 'no matches'})) return <NoResults />
return (
<MatchList isRefreshing={isRefreshing}>
{picker.snapshot.context.matches.map(/* ... */)}
</MatchList>
)
}When users type quickly, earlier slow requests may complete after later fast requests. The picker automatically ignores stale results to prevent them from overwriting fresh data.
If getMatches throws or rejects, the error is captured in snapshot.context.error. The picker transitions to 'no matches' state and continues to function.
function EmojiPickerPlugin() {
const picker = useTypeaheadPicker(emojiPicker)
const {error} = picker.snapshot.context
if (error) {
return (
<div>
<p>Failed to load: {error.message}</p>
<button onClick={() => picker.send({type: 'dismiss'})}>Dismiss</button>
</div>
)
}
// ... render matches
}The error is cleared when the picker returns to idle (e.g., via Escape or cursor movement).
The optional onDismiss callback runs when the picker is dismissed (Escape, Enter/Tab with no matches, or programmatically). Without onDismiss, dismissing simply closes the picker and leaves the typed text in place.
For most pickers, you should not use onDismiss to delete text. If a user types @ and dismisses, they likely wanted to type a literal @.
onDismiss payload:
| Property | Description |
|---|---|
event.patternSelection |
Selection range covering the trigger + keyword (e.g., @john) |
snapshot |
Current editor snapshot |
The onSelect callback receives more than just the event. The full payload includes access to the editor snapshot, which is useful for generating keys, accessing the schema, or reading the current editor state.
const commandPicker = defineTypeaheadPicker<CommandMatch>({
trigger: /^\//,
keyword: /\w*/,
getMatches: ({keyword}) => searchCommands(keyword),
onSelect: [
({event, snapshot}) => {
// Access schema to check for block object fields
const blockObjectSchema = snapshot.context.schema.blockObjects.find(
(bo) => bo.name === event.match.blockType,
)
// Generate unique keys for inserted blocks
const blockKey = snapshot.context.keyGenerator()
return [
raise({type: 'delete', at: event.patternSelection}),
raise({
type: 'insert.block',
block: {_type: event.match.blockType, _key: blockKey},
}),
]
},
],
})onSelect payload:
| Property | Description |
|---|---|
event |
The select event with match, keyword, and patternSelection |
snapshot |
Current editor snapshot with context.schema, context.keyGenerator(), etc. |
Keep your match lists reasonably sized for smooth keyboard navigation:
- Recommended: Return 10-50 matches maximum
- Large datasets: Filter on the server or use pagination
- Infinite lists: Consider virtualizing if rendering many items
getMatches: async ({keyword}) => {
const results = await api.searchUsers(keyword)
return results.slice(0, 20) // Limit to 20 matches
}Choose debounce values based on your data source:
| Source | Recommended debounceMs |
|---|---|
| Local array filter | 0 (no debounce) |
| Expensive local Fuse.js search | 50-100 |
| Fast API endpoint | 150-200 |
| Slow API endpoint | 200-300 |
// Local data - no debounce needed
const emojiPicker = defineTypeaheadPicker({
trigger: /:/,
keyword: /\S*/,
getMatches: ({keyword}) => filterEmojis(keyword), // Fast local filter
// ...
})
// API data - debounce to reduce requests
const mentionPicker = defineTypeaheadPicker({
mode: 'async',
debounceMs: 200,
trigger: /@/,
keyword: /\w*/,
getMatches: async ({keyword}) => api.searchUsers(keyword),
// ...
})- Avoid storing large datasets in component state
- For emoji pickers, consider lazy-loading the emoji database
- Clean up listeners when components unmount (the hook handles this automatically)
The picker manages keyboard navigation and selection internally, but you're responsible for the UI semantics.
function PickerUI() {
const picker = useTypeaheadPicker(definition)
const {matches, selectedIndex} = picker.snapshot.context
return (
<ul role="listbox" aria-label="Suggestions">
{matches.map((match, index) => (
<li
key={match.key}
role="option"
aria-selected={index === selectedIndex}
onMouseEnter={() => picker.send({type: 'navigate to', index})}
onClick={() => picker.send({type: 'select'})}
>
{match.label}
</li>
))}
</ul>
)
}The following keyboard shortcuts are handled automatically by the picker:
| Key | Action |
|---|---|
↑ / ↓ |
Navigate through matches |
Enter |
Insert selected match |
Tab |
Insert selected match |
Escape |
Dismiss picker |
Space |
Dismiss picker (configurable) |
- Announce match count changes with live regions if desired
- Ensure selected item is visible (scroll into view)
- Provide clear labels for what each match represents
- Check trigger: Must be a single-character trigger (e.g.,
/:/,/@/) - Check position anchors:
^means start of block, not start of line.hello /commandwon't match/^\// - Check for conflicts: Only one picker can be active at a time
- Avoid multi-character triggers: Triggers like
/##/don't work because the picker only activates on newly typed single characters - Check guard: If you have a
guardconfigured, ensure it's returningtruewhen activation should be allowed
- Check
delimiter: Must be set (e.g.,delimiter: ':') - Check match type: Matches must include
type: 'exact' | 'partial' - Check for exact match: Auto-completion only triggers when exactly one match has
type: 'exact' - Check keyword pattern: The keyword pattern must allow the delimiter character at the boundary. Use
\S*(matches any non-whitespace) whendelimiter: ':'
- For async pickers, the race condition handling should prevent this automatically
- If issues persist, check that
getMatchesdoesn't cache results incorrectly
- Ensure your onSelect includes focus restoration if needed:
onSelect: [ ({event}) => [ raise({type: 'delete', at: event.patternSelection}), raise({type: 'insert.text', text: event.match.emoji}), effect(({send}) => { send({type: 'focus'}) }), ], ]