@portabletext/plugin-typeahead-picker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +495 -0
- package/dist/index.d.ts +459 -0
- package/dist/index.js +1117 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 - 2026 Sanity.io
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
# `@portabletext/plugin-typeahead-picker`
|
|
2
|
+
|
|
3
|
+
> Generic typeahead picker infrastructure for the Portable Text Editor
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import {raise} from '@portabletext/editor/behaviors'
|
|
11
|
+
import {
|
|
12
|
+
defineTypeaheadPicker,
|
|
13
|
+
useTypeaheadPicker,
|
|
14
|
+
type AutoCompleteMatch,
|
|
15
|
+
} from '@portabletext/plugin-typeahead-picker'
|
|
16
|
+
|
|
17
|
+
// With `autoCompleteWith` configured, matches must include `type: 'exact' | 'partial'`
|
|
18
|
+
// for auto-completion to work. Use `AutoCompleteMatch` as the base type.
|
|
19
|
+
type EmojiMatch = AutoCompleteMatch & {
|
|
20
|
+
key: string
|
|
21
|
+
emoji: string
|
|
22
|
+
shortcode: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const emojiPicker = defineTypeaheadPicker<EmojiMatch>({
|
|
26
|
+
// Pattern to match trigger + keyword. The capture group (\S*) becomes the keyword.
|
|
27
|
+
// This matches `:` followed by any non-whitespace characters.
|
|
28
|
+
pattern: /:(\S*)/,
|
|
29
|
+
|
|
30
|
+
// Optional autoCompleteWith enables auto-completion.
|
|
31
|
+
// Typing `:joy:` will auto-insert if "joy" is an exact match.
|
|
32
|
+
autoCompleteWith: ':',
|
|
33
|
+
|
|
34
|
+
// Return matches for the keyword. Can be sync or async (with mode: 'async').
|
|
35
|
+
getMatches: ({keyword}) => searchEmojis(keyword),
|
|
36
|
+
|
|
37
|
+
// Actions to execute when a match is selected (Enter/Tab or click).
|
|
38
|
+
// Receives the event containing the selected match and pattern selection.
|
|
39
|
+
actions: [
|
|
40
|
+
({event}) => [
|
|
41
|
+
raise({type: 'delete', at: event.patternSelection}), // Delete `:joy`
|
|
42
|
+
raise({type: 'insert.text', text: event.match.emoji}), // Insert 😂
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
function EmojiPicker() {
|
|
48
|
+
// Activate the picker and get its current state
|
|
49
|
+
const picker = useTypeaheadPicker(emojiPicker)
|
|
50
|
+
|
|
51
|
+
// Don't render anything when picker is inactive
|
|
52
|
+
if (picker.snapshot.matches('idle')) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const {keyword, matches, selectedIndex} = picker.snapshot.context
|
|
57
|
+
|
|
58
|
+
if (matches.length === 0) {
|
|
59
|
+
return <div>No emojis found for "{keyword}"</div>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<ul>
|
|
64
|
+
{matches.map((match, index) => (
|
|
65
|
+
<li
|
|
66
|
+
key={match.key}
|
|
67
|
+
aria-selected={index === selectedIndex}
|
|
68
|
+
// Optional: enable mouse hover to select
|
|
69
|
+
onMouseEnter={() => picker.send({type: 'navigate to', index})}
|
|
70
|
+
// Optional: enable click to insert
|
|
71
|
+
onClick={() => picker.send({type: 'select'})}
|
|
72
|
+
>
|
|
73
|
+
{match.emoji} {match.shortcode}
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ul>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## How It Works
|
|
82
|
+
|
|
83
|
+
The picker activates when users type text matching the `pattern` (e.g., `:smile` or `@john`).
|
|
84
|
+
|
|
85
|
+
- **Keyboard shortcuts are built-in**:
|
|
86
|
+
- `Enter` or `Tab` inserts the selected match
|
|
87
|
+
- `↑` / `↓` navigate through matches
|
|
88
|
+
- `Esc` dismisses the picker
|
|
89
|
+
- **Mouse interactions are opt-in**: Use `send({type: 'navigate to', index})` and `send({type: 'select'})` to enable hover and click
|
|
90
|
+
- **Auto-completion**: With `autoCompleteWith` configured, typing the delimiter after an exact match auto-inserts it (e.g., `:joy:` auto-inserts the emoji)
|
|
91
|
+
|
|
92
|
+
## Examples
|
|
93
|
+
|
|
94
|
+
### Emoji picker
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const emojiPicker = defineTypeaheadPicker<EmojiMatch>({
|
|
98
|
+
pattern: /:(\S*)/,
|
|
99
|
+
autoCompleteWith: ':',
|
|
100
|
+
getMatches: ({keyword}) => searchEmojis(keyword),
|
|
101
|
+
actions: [
|
|
102
|
+
({event}) => [
|
|
103
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
104
|
+
raise({type: 'insert.text', text: event.match.emoji}),
|
|
105
|
+
],
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`:joy:` auto-inserts the emoji
|
|
111
|
+
|
|
112
|
+
### Mention picker (async with debounce)
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// Without `autoCompleteWith`, the `type` field is not required on matches.
|
|
116
|
+
// MentionMatch can just be: { id: string; name: string }
|
|
117
|
+
const mentionPicker = defineTypeaheadPicker<MentionMatch>({
|
|
118
|
+
mode: 'async',
|
|
119
|
+
pattern: /@(\w*)/,
|
|
120
|
+
debounceMs: 200,
|
|
121
|
+
getMatches: async ({keyword}) => api.searchUsers(keyword),
|
|
122
|
+
actions: [
|
|
123
|
+
({event}) => [
|
|
124
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
125
|
+
raise({
|
|
126
|
+
type: 'insert.child',
|
|
127
|
+
child: {_type: 'mention', userId: event.match.id},
|
|
128
|
+
}),
|
|
129
|
+
],
|
|
130
|
+
],
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`@john` shows matches after 200ms pause, user selects with Enter/Tab
|
|
135
|
+
|
|
136
|
+
### Slash command picker (start of block only)
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
// Without `autoCompleteWith`, the `type` field is not required on matches.
|
|
140
|
+
const commandPicker = defineTypeaheadPicker<CommandMatch>({
|
|
141
|
+
pattern: /^\/(\w*)/, // ^ anchors to start of block
|
|
142
|
+
getMatches: ({keyword}) => searchCommands(keyword),
|
|
143
|
+
actions: [
|
|
144
|
+
({event}) => {
|
|
145
|
+
switch (event.match.command) {
|
|
146
|
+
case 'h1':
|
|
147
|
+
case 'h2':
|
|
148
|
+
case 'h3':
|
|
149
|
+
return [
|
|
150
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
151
|
+
raise({type: 'style.toggle', style: event.match.command}),
|
|
152
|
+
]
|
|
153
|
+
case 'image':
|
|
154
|
+
return [
|
|
155
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
156
|
+
raise({type: 'insert.block', block: {_type: 'image'}}),
|
|
157
|
+
]
|
|
158
|
+
default:
|
|
159
|
+
return [raise({type: 'delete', at: event.patternSelection})]
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`/heading` shows matching commands, but only when `/` is at the start of a block. Text like `hello /heading` will NOT trigger the picker.
|
|
167
|
+
|
|
168
|
+
## API Reference
|
|
169
|
+
|
|
170
|
+
### `defineTypeaheadPicker(config)`
|
|
171
|
+
|
|
172
|
+
Creates a picker definition to pass to `useTypeaheadPicker`.
|
|
173
|
+
|
|
174
|
+
**Config:**
|
|
175
|
+
|
|
176
|
+
| Property | Type | Description |
|
|
177
|
+
| ------------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
178
|
+
| `pattern` | `RegExp` | Pattern for matching trigger + keyword. Use a capture group for the keyword (e.g., `/:(\S*)/`, `/@(\w*)/`). Can include position anchors like `^` for start-of-block triggers. |
|
|
179
|
+
| `autoCompleteWith` | `string?` | Optional delimiter that triggers auto-completion (e.g., `:` for `:joy:`) |
|
|
180
|
+
| `mode` | `'sync' \| 'async'` | Whether `getMatches` returns synchronously or a Promise (default: `'sync'`) |
|
|
181
|
+
| `debounceMs` | `number?` | Delay in ms before calling `getMatches`. Useful for both async (API calls) and sync (expensive local search) modes. (default: `0`) |
|
|
182
|
+
| `getMatches` | `(ctx: {keyword: string}) => TMatch[]` | Function that returns matches for the keyword |
|
|
183
|
+
| `actions` | `Array<TypeaheadSelectActionSet>` | Actions to execute when a match is selected |
|
|
184
|
+
|
|
185
|
+
**Pattern rules:**
|
|
186
|
+
|
|
187
|
+
- If pattern has capture groups: keyword = first capture group
|
|
188
|
+
- If no capture group: keyword = entire match
|
|
189
|
+
- Position anchors (`^`) allow start-of-block constraints
|
|
190
|
+
- Regex flags are ignored (the picker normalizes patterns internally)
|
|
191
|
+
|
|
192
|
+
**How triggering works:**
|
|
193
|
+
|
|
194
|
+
The picker activates the moment a trigger character is typed - not later when more text matches. This is the key to understanding pattern requirements:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
User types `:` → Pattern /:(\S*)/ matches → Picker activates with keyword ""
|
|
198
|
+
User types `j` → Keyword updates to "j" (via selection tracking)
|
|
199
|
+
User types `o` → Keyword updates to "jo"
|
|
200
|
+
User types `y` → Keyword updates to "joy"
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The pattern must match **immediately when the trigger is typed**. After activation, the keyword is tracked via editor selection changes, not by re-running the pattern.
|
|
204
|
+
|
|
205
|
+
**Why some patterns don't work:**
|
|
206
|
+
|
|
207
|
+
Multi-character triggers like `##` fail because the picker can only activate on the character you just typed:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
User types `#` → Pattern /##(\w*)/ doesn't match yet → Nothing happens
|
|
211
|
+
User types `#` → Pattern matches "##", but the first # was already there
|
|
212
|
+
Picker only activates on newly typed triggers, not existing text
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Same issue with patterns requiring specific characters mid-keyword like `/:(\w*)-(\w*)/`:
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
User types `:foo` → Pattern doesn't match yet (needs a `-`)
|
|
219
|
+
User types `-` → Pattern matches `:foo-`, but `:foo` was already there
|
|
220
|
+
Picker only activates on newly typed triggers
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Pattern compatibility summary:**
|
|
224
|
+
|
|
225
|
+
| Pattern | Example input | Works? | Why |
|
|
226
|
+
| ---------------- | ------------- | ------ | --------------------------------- |
|
|
227
|
+
| `/:(\S*)/` | `:joy` | ✅ | `:` alone triggers, keyword grows |
|
|
228
|
+
| `/@(\w*)/` | `@john` | ✅ | `@` alone triggers, keyword grows |
|
|
229
|
+
| `/^\/(\w*)/` | `/cmd` | ✅ | `/` at block start triggers |
|
|
230
|
+
| `/##(\w*)/` | `##tag` | ❌ | `#` alone doesn't match pattern |
|
|
231
|
+
| `/:(\w*)-(\w*)/` | `:a-b` | ❌ | `:` alone doesn't match pattern |
|
|
232
|
+
|
|
233
|
+
**autoCompleteWith requirements:**
|
|
234
|
+
|
|
235
|
+
When using `autoCompleteWith`, the delimiter character must be included in the keyword's character class, otherwise typing it dismisses the picker:
|
|
236
|
+
|
|
237
|
+
| Pattern | autoCompleteWith | Example | Works? | Why |
|
|
238
|
+
| ---------- | ---------------- | -------- | ------ | --------------------------------------------------- |
|
|
239
|
+
| `/:(\S*)/` | `:` | `:joy:` | ✅ | `\S` matches `:`, cursor stays in match |
|
|
240
|
+
| `/:(\w*)/` | `:` | `:joy:` | ✅ | Cursor at match boundary, still valid |
|
|
241
|
+
| `/#(\w*)/` | `#` | `#tag#` | ✅ | Cursor at match boundary, still valid |
|
|
242
|
+
| `/#(\w*)/` | `##` | `#tag##` | ❌ | First `#` moves cursor past match, picker dismisses |
|
|
243
|
+
|
|
244
|
+
### `useTypeaheadPicker(definition)`
|
|
245
|
+
|
|
246
|
+
React hook that activates a picker and returns its state.
|
|
247
|
+
|
|
248
|
+
**Returns:**
|
|
249
|
+
|
|
250
|
+
| Property | Description |
|
|
251
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
252
|
+
| `snapshot.matches(state)` | Check picker state: `'idle'`, `{active: 'loading'}`, `{active: 'no matches'}`, `{active: 'showing matches'}` |
|
|
253
|
+
| `snapshot.context.keyword` | The current keyword (extracted from capture group) |
|
|
254
|
+
| `snapshot.context.matches` | Array of matches from `getMatches` |
|
|
255
|
+
| `snapshot.context.selectedIndex` | Index of the currently selected match |
|
|
256
|
+
| `send(event)` | Dispatch events: `{type: 'select'}`, `{type: 'dismiss'}`, `{type: 'navigate to', index}` |
|
|
257
|
+
| `snapshot.context.error` | Error from `getMatches` if it threw/rejected, otherwise `undefined` |
|
|
258
|
+
|
|
259
|
+
## Async Mode
|
|
260
|
+
|
|
261
|
+
When `mode: 'async'` is configured, the picker handles asynchronous `getMatches` functions with loading states and race condition protection.
|
|
262
|
+
|
|
263
|
+
### Loading States
|
|
264
|
+
|
|
265
|
+
Use `snapshot.matches()` to check nested loading states:
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
function MentionPicker() {
|
|
269
|
+
const picker = useTypeaheadPicker(mentionPicker)
|
|
270
|
+
|
|
271
|
+
// Initial loading (no results yet)
|
|
272
|
+
const isLoading = picker.snapshot.matches({active: 'loading'})
|
|
273
|
+
|
|
274
|
+
// Background refresh (showing stale results while fetching new ones)
|
|
275
|
+
const isRefreshing = picker.snapshot.matches({
|
|
276
|
+
active: {'showing matches': 'loading'},
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// No matches, but still fetching (to avoid flicker)
|
|
280
|
+
const isLoadingNoMatches = picker.snapshot.matches({
|
|
281
|
+
active: {'no matches': 'loading'},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
if (isLoading) return <Spinner />
|
|
285
|
+
if (picker.snapshot.matches({active: 'no matches'})) return <NoResults />
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<MatchList isRefreshing={isRefreshing}>
|
|
289
|
+
{picker.snapshot.context.matches.map(/* ... */)}
|
|
290
|
+
</MatchList>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Race Condition Handling
|
|
296
|
+
|
|
297
|
+
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.
|
|
298
|
+
|
|
299
|
+
## Error Handling
|
|
300
|
+
|
|
301
|
+
If `getMatches` throws or rejects, the error is captured in `snapshot.context.error`. The picker transitions to `'no matches'` state and continues to function.
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
function EmojiPicker() {
|
|
305
|
+
const picker = useTypeaheadPicker(emojiPicker)
|
|
306
|
+
const {error} = picker.snapshot.context
|
|
307
|
+
|
|
308
|
+
if (error) {
|
|
309
|
+
return (
|
|
310
|
+
<div>
|
|
311
|
+
<p>Failed to load: {error.message}</p>
|
|
312
|
+
<button onClick={() => picker.send({type: 'dismiss'})}>Dismiss</button>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ... render matches
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The error is cleared when the picker returns to idle (e.g., via Escape or cursor movement).
|
|
322
|
+
|
|
323
|
+
## Advanced Actions
|
|
324
|
+
|
|
325
|
+
Action functions receive 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.
|
|
326
|
+
|
|
327
|
+
```tsx
|
|
328
|
+
const commandPicker = defineTypeaheadPicker<CommandMatch>({
|
|
329
|
+
pattern: /^\/(\w*)/,
|
|
330
|
+
getMatches: ({keyword}) => searchCommands(keyword),
|
|
331
|
+
actions: [
|
|
332
|
+
({event, snapshot}) => {
|
|
333
|
+
// Access schema to check for block object fields
|
|
334
|
+
const blockObjectSchema = snapshot.context.schema.blockObjects.find(
|
|
335
|
+
(bo) => bo.name === event.match.blockType,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
// Generate unique keys for inserted blocks
|
|
339
|
+
const blockKey = snapshot.context.keyGenerator()
|
|
340
|
+
|
|
341
|
+
return [
|
|
342
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
343
|
+
raise({
|
|
344
|
+
type: 'insert.block',
|
|
345
|
+
block: {_type: event.match.blockType, _key: blockKey},
|
|
346
|
+
}),
|
|
347
|
+
]
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Action payload:**
|
|
354
|
+
|
|
355
|
+
| Property | Description |
|
|
356
|
+
| ---------- | ----------------------------------------------------------------------------- |
|
|
357
|
+
| `event` | The select event with `match`, `keyword`, and `patternSelection` |
|
|
358
|
+
| `snapshot` | Current editor snapshot with `context.schema`, `context.keyGenerator()`, etc. |
|
|
359
|
+
|
|
360
|
+
## Performance Guidelines
|
|
361
|
+
|
|
362
|
+
### Match List Size
|
|
363
|
+
|
|
364
|
+
Keep your match lists reasonably sized for smooth keyboard navigation:
|
|
365
|
+
|
|
366
|
+
- **Recommended**: Return 10-50 matches maximum
|
|
367
|
+
- **Large datasets**: Filter on the server or use pagination
|
|
368
|
+
- **Infinite lists**: Consider virtualizing if rendering many items
|
|
369
|
+
|
|
370
|
+
```tsx
|
|
371
|
+
getMatches: async ({keyword}) => {
|
|
372
|
+
const results = await api.searchUsers(keyword)
|
|
373
|
+
return results.slice(0, 20) // Limit to 20 matches
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Debounce Timing
|
|
378
|
+
|
|
379
|
+
Choose debounce values based on your data source:
|
|
380
|
+
|
|
381
|
+
| Source | Recommended `debounceMs` |
|
|
382
|
+
| ------------------------------ | ------------------------ |
|
|
383
|
+
| Local array filter | `0` (no debounce) |
|
|
384
|
+
| Expensive local Fuse.js search | `50-100` |
|
|
385
|
+
| Fast API endpoint | `150-200` |
|
|
386
|
+
| Slow API endpoint | `200-300` |
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
// Local data - no debounce needed
|
|
390
|
+
const emojiPicker = defineTypeaheadPicker({
|
|
391
|
+
pattern: /:(\S*)/,
|
|
392
|
+
getMatches: ({keyword}) => filterEmojis(keyword), // Fast local filter
|
|
393
|
+
// ...
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// API data - debounce to reduce requests
|
|
397
|
+
const mentionPicker = defineTypeaheadPicker({
|
|
398
|
+
mode: 'async',
|
|
399
|
+
debounceMs: 200,
|
|
400
|
+
pattern: /@(\w*)/,
|
|
401
|
+
getMatches: async ({keyword}) => api.searchUsers(keyword),
|
|
402
|
+
// ...
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Memory Considerations
|
|
407
|
+
|
|
408
|
+
- Avoid storing large datasets in component state
|
|
409
|
+
- For emoji pickers, consider lazy-loading the emoji database
|
|
410
|
+
- Clean up listeners when components unmount (the hook handles this automatically)
|
|
411
|
+
|
|
412
|
+
## Accessibility
|
|
413
|
+
|
|
414
|
+
The picker manages keyboard navigation and selection internally, but you're responsible for the UI semantics.
|
|
415
|
+
|
|
416
|
+
### Recommended ARIA Attributes
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
function PickerUI() {
|
|
420
|
+
const picker = useTypeaheadPicker(definition)
|
|
421
|
+
const {matches, selectedIndex} = picker.snapshot.context
|
|
422
|
+
|
|
423
|
+
return (
|
|
424
|
+
<ul role="listbox" aria-label="Suggestions">
|
|
425
|
+
{matches.map((match, index) => (
|
|
426
|
+
<li
|
|
427
|
+
key={match.key}
|
|
428
|
+
role="option"
|
|
429
|
+
aria-selected={index === selectedIndex}
|
|
430
|
+
onMouseEnter={() => picker.send({type: 'navigate to', index})}
|
|
431
|
+
onClick={() => picker.send({type: 'select'})}
|
|
432
|
+
>
|
|
433
|
+
{match.label}
|
|
434
|
+
</li>
|
|
435
|
+
))}
|
|
436
|
+
</ul>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Keyboard Handling
|
|
442
|
+
|
|
443
|
+
The following keyboard shortcuts are handled automatically by the picker:
|
|
444
|
+
|
|
445
|
+
| Key | Action |
|
|
446
|
+
| --------- | ----------------------------- |
|
|
447
|
+
| `↑` / `↓` | Navigate through matches |
|
|
448
|
+
| `Enter` | Insert selected match |
|
|
449
|
+
| `Tab` | Insert selected match |
|
|
450
|
+
| `Escape` | Dismiss picker |
|
|
451
|
+
| `Space` | Dismiss picker (configurable) |
|
|
452
|
+
|
|
453
|
+
### Screen Reader Considerations
|
|
454
|
+
|
|
455
|
+
- Announce match count changes with live regions if desired
|
|
456
|
+
- Ensure selected item is visible (scroll into view)
|
|
457
|
+
- Provide clear labels for what each match represents
|
|
458
|
+
|
|
459
|
+
## Troubleshooting
|
|
460
|
+
|
|
461
|
+
### Picker doesn't activate
|
|
462
|
+
|
|
463
|
+
- **Check pattern**: Ensure your regex has a capture group for the keyword: `/:(\S*)/` not `/:\S*/`
|
|
464
|
+
- **Check position anchors**: `^` means start of block, not start of line. `hello /command` won't match `/^\/(\w*)/`
|
|
465
|
+
- **Check for conflicts**: Only one picker can be active at a time
|
|
466
|
+
- **Avoid multi-character triggers**: Patterns like `/##(\w*)/` don't work because the picker only activates on newly typed triggers, not existing text
|
|
467
|
+
|
|
468
|
+
### Auto-completion doesn't work
|
|
469
|
+
|
|
470
|
+
- **Check `autoCompleteWith`**: Must be set (e.g., `autoCompleteWith: ':'`)
|
|
471
|
+
- **Check match type**: Matches must include `type: 'exact' | 'partial'`
|
|
472
|
+
- **Check for exact match**: Auto-completion only triggers when exactly one match has `type: 'exact'`
|
|
473
|
+
- **Check character class**: The keyword character class must match the `autoCompleteWith` character. Use `\S*` (matches any non-whitespace including `:`) rather than `\w*` (only matches word characters) when `autoCompleteWith: ':'`
|
|
474
|
+
|
|
475
|
+
### Stale matches appear
|
|
476
|
+
|
|
477
|
+
- For async pickers, the race condition handling should prevent this automatically
|
|
478
|
+
- If issues persist, check that `getMatches` doesn't cache results incorrectly
|
|
479
|
+
|
|
480
|
+
### Focus issues after selection
|
|
481
|
+
|
|
482
|
+
- Ensure your actions include focus restoration if needed:
|
|
483
|
+
```tsx
|
|
484
|
+
actions: [
|
|
485
|
+
({event}) => [
|
|
486
|
+
raise({type: 'delete', at: event.patternSelection}),
|
|
487
|
+
raise({type: 'insert.text', text: event.match.emoji}),
|
|
488
|
+
],
|
|
489
|
+
() => [
|
|
490
|
+
effect(({send}) => {
|
|
491
|
+
send({type: 'focus'})
|
|
492
|
+
}),
|
|
493
|
+
],
|
|
494
|
+
]
|
|
495
|
+
```
|