@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.
@@ -0,0 +1,459 @@
1
+ import type {EditorSelection} from '@portabletext/editor'
2
+ import type {BehaviorActionSet} from '@portabletext/editor/behaviors'
3
+
4
+ declare type AsyncConfigWithAutoComplete<TMatch extends AutoCompleteMatch> =
5
+ BaseConfigWithAutoComplete<TMatch> & {
6
+ mode: 'async'
7
+ debounceMs?: number
8
+ getMatches: (context: {keyword: string}) => Promise<Array<TMatch>>
9
+ }
10
+
11
+ declare type AsyncConfigWithoutAutoComplete<TMatch extends object> =
12
+ BaseConfigWithoutAutoComplete<TMatch> & {
13
+ mode: 'async'
14
+ debounceMs?: number
15
+ getMatches: (context: {keyword: string}) => Promise<Array<TMatch>>
16
+ }
17
+
18
+ /**
19
+ * Match type for pickers with auto-completion support.
20
+ * Use this when `autoCompleteWith` is configured.
21
+ *
22
+ * The `type` property indicates how well the match corresponds to the keyword:
23
+ * - `'exact'` - The keyword matches this item exactly (e.g., keyword `joy` matches emoji `:joy:`)
24
+ * - `'partial'` - The keyword partially matches this item (e.g., keyword `jo` matches `:joy:`)
25
+ *
26
+ * When `autoCompleteWith` is configured and there's exactly one `'exact'` match,
27
+ * the picker will auto-insert that match.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * // With autoCompleteWith - type field required for auto-completion
32
+ * type EmojiMatch = AutoCompleteMatch & {
33
+ * key: string
34
+ * emoji: string
35
+ * shortcode: string
36
+ * }
37
+ * ```
38
+ *
39
+ * @public
40
+ */
41
+ export declare type AutoCompleteMatch = {
42
+ type: 'exact' | 'partial'
43
+ }
44
+
45
+ declare type BaseConfigWithAutoComplete<TMatch extends AutoCompleteMatch> = {
46
+ pattern: RegExp
47
+ autoCompleteWith: string
48
+ actions: Array<TypeaheadSelectActionSet<TMatch>>
49
+ }
50
+
51
+ declare type BaseConfigWithoutAutoComplete<TMatch extends object> = {
52
+ pattern: RegExp
53
+ autoCompleteWith?: undefined
54
+ actions: Array<TypeaheadSelectActionSet<TMatch>>
55
+ }
56
+
57
+ /**
58
+ * Creates a typeahead picker definition to use with {@link useTypeaheadPicker}.
59
+ *
60
+ * @example Emoji picker with auto-complete
61
+ * ```ts
62
+ * const emojiPicker = defineTypeaheadPicker({
63
+ * pattern: /:(\S*)/,
64
+ * autoCompleteWith: ':',
65
+ * getMatches: ({keyword}) => searchEmojis(keyword),
66
+ * actions: [({event}) => [
67
+ * raise({type: 'delete', at: event.patternSelection}),
68
+ * raise({type: 'insert.text', text: event.match.emoji}),
69
+ * ]],
70
+ * })
71
+ * ```
72
+ *
73
+ * @example Async mention picker
74
+ * ```ts
75
+ * const mentionPicker = defineTypeaheadPicker({
76
+ * mode: 'async',
77
+ * pattern: /@(\w*)/,
78
+ * debounceMs: 200,
79
+ * getMatches: async ({keyword}) => api.searchUsers(keyword),
80
+ * actions: [({event}) => [
81
+ * raise({type: 'delete', at: event.patternSelection}),
82
+ * raise({type: 'insert.text', text: event.match.name}),
83
+ * ]],
84
+ * })
85
+ * ```
86
+ *
87
+ * @example Slash commands at start of block
88
+ * ```ts
89
+ * const slashCommandPicker = defineTypeaheadPicker({
90
+ * pattern: /^\/(\w*)/, // ^ anchors to start of block
91
+ * getMatches: ({keyword}) => filterCommands(keyword),
92
+ * actions: [({event}) => [
93
+ * raise({type: 'delete', at: event.patternSelection}),
94
+ * raise(event.match.action),
95
+ * ]],
96
+ * })
97
+ * ```
98
+ *
99
+ * @public
100
+ */
101
+ export declare function defineTypeaheadPicker<TMatch extends object>(
102
+ config: SyncConfigWithoutAutoComplete<TMatch>,
103
+ ): TypeaheadPickerDefinition<TMatch>
104
+
105
+ /** @public */
106
+ export declare function defineTypeaheadPicker<TMatch extends AutoCompleteMatch>(
107
+ config: SyncConfigWithAutoComplete<TMatch>,
108
+ ): TypeaheadPickerDefinition<TMatch>
109
+
110
+ /** @public */
111
+ export declare function defineTypeaheadPicker<TMatch extends object>(
112
+ config: AsyncConfigWithoutAutoComplete<TMatch>,
113
+ ): TypeaheadPickerDefinition<TMatch>
114
+
115
+ /** @public */
116
+ export declare function defineTypeaheadPicker<TMatch extends AutoCompleteMatch>(
117
+ config: AsyncConfigWithAutoComplete<TMatch>,
118
+ ): TypeaheadPickerDefinition<TMatch>
119
+
120
+ /**
121
+ * Function that retrieves matches for a given keyword.
122
+ *
123
+ * Return synchronously for local data (like emoji shortcodes) or
124
+ * asynchronously for remote data (like user mentions from an API).
125
+ *
126
+ * The `mode` option in `defineTypeaheadPicker` enforces the correct return type:
127
+ * - `mode: 'sync'` (default) - Must return `Array<TMatch>`
128
+ * - `mode: 'async'` - Must return `Promise<Array<TMatch>>`
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * // Sync: filter a local array
133
+ * const getEmojis: GetMatches<EmojiMatch> = ({keyword}) =>
134
+ * emojis.filter(e => e.shortcode.includes(keyword))
135
+ *
136
+ * // Async: fetch from an API
137
+ * const getUsers: GetMatches<UserMatch> = async ({keyword}) =>
138
+ * await api.searchUsers(keyword)
139
+ * ```
140
+ *
141
+ * @public
142
+ */
143
+ export declare type GetMatches<TMatch extends object> = (context: {
144
+ keyword: string
145
+ }) => Array<TMatch> | Promise<Array<TMatch>>
146
+
147
+ declare type SyncConfigWithAutoComplete<TMatch extends AutoCompleteMatch> =
148
+ BaseConfigWithAutoComplete<TMatch> & {
149
+ mode?: 'sync'
150
+ debounceMs?: number
151
+ getMatches: (context: {keyword: string}) => Array<TMatch>
152
+ }
153
+
154
+ declare type SyncConfigWithoutAutoComplete<TMatch extends object> =
155
+ BaseConfigWithoutAutoComplete<TMatch> & {
156
+ mode?: 'sync'
157
+ debounceMs?: number
158
+ getMatches: (context: {keyword: string}) => Array<TMatch>
159
+ }
160
+
161
+ /**
162
+ * The picker instance returned by {@link useTypeaheadPicker}.
163
+ *
164
+ * Provides a state machine-like interface for building picker UI:
165
+ * - Use `snapshot.matches()` to check the current state and render accordingly
166
+ * - Use `snapshot.context` to access the keyword, matches, and selected index
167
+ * - Use `send()` to dispatch events like select, dismiss, or navigate
168
+ *
169
+ * @example
170
+ * ```tsx
171
+ * function EmojiPicker() {
172
+ * const picker = useTypeaheadPicker(emojiPickerDefinition)
173
+ *
174
+ * if (picker.snapshot.matches('idle')) return null
175
+ * if (picker.snapshot.matches({active: 'loading'})) return <Spinner />
176
+ * if (picker.snapshot.matches({active: 'no matches'})) return <NoResults />
177
+ *
178
+ * const {matches, selectedIndex} = picker.snapshot.context
179
+ *
180
+ * return (
181
+ * <ul>
182
+ * {matches.map((match, i) => (
183
+ * <li
184
+ * key={match.key}
185
+ * aria-selected={i === selectedIndex}
186
+ * onMouseEnter={() => picker.send({type: 'navigate to', index: i})}
187
+ * onClick={() => picker.send({type: 'select'})}
188
+ * >
189
+ * {match.emoji} {match.shortcode}
190
+ * </li>
191
+ * ))}
192
+ * </ul>
193
+ * )
194
+ * }
195
+ * ```
196
+ *
197
+ * @public
198
+ */
199
+ export declare type TypeaheadPicker<TMatch extends object> = {
200
+ snapshot: {
201
+ /**
202
+ * Check if the picker is in a specific state.
203
+ * @see {@link TypeaheadPickerState} for available states
204
+ */
205
+ matches: (state: TypeaheadPickerState) => boolean
206
+ /**
207
+ * Current picker data including keyword, matches, and selection.
208
+ */
209
+ context: TypeaheadPickerContext<TMatch>
210
+ }
211
+ /**
212
+ * Dispatch an event to the picker.
213
+ * @see {@link TypeaheadPickerEvent} for available events
214
+ */
215
+ send: (event: TypeaheadPickerEvent) => void
216
+ }
217
+
218
+ /**
219
+ * Current picker data, accessible via `snapshot.context`.
220
+ *
221
+ * @public
222
+ */
223
+ export declare type TypeaheadPickerContext<TMatch> = {
224
+ /** The extracted keyword from the trigger pattern (e.g., `joy` from `:joy`) */
225
+ keyword: string
226
+ /** The current list of matches returned by `getMatches` */
227
+ matches: Array<TMatch>
228
+ /** Index of the currently selected match (for keyboard navigation and highlighting) */
229
+ selectedIndex: number
230
+ /** Error from `getMatches` if it threw, otherwise `undefined` */
231
+ error: Error | undefined
232
+ }
233
+
234
+ /**
235
+ * Configuration object that defines a typeahead picker's behavior.
236
+ *
237
+ * Create using {@link (defineTypeaheadPicker:1)} and pass to {@link useTypeaheadPicker}.
238
+ *
239
+ * @example
240
+ * ```ts
241
+ * const emojiPicker = defineTypeaheadPicker({
242
+ * pattern: /:(\S*)/,
243
+ * autoCompleteWith: ':',
244
+ * getMatches: ({keyword}) => searchEmojis(keyword),
245
+ * actions: [insertEmojiAction],
246
+ * })
247
+ * ```
248
+ *
249
+ * @public
250
+ */
251
+ export declare type TypeaheadPickerDefinition<TMatch extends object = object> =
252
+ TypeaheadPickerDefinitionBase<TMatch> & {
253
+ /** @internal Unique identifier for this picker definition */
254
+ readonly _id: symbol
255
+ /**
256
+ * Whether `getMatches` returns synchronously or asynchronously.
257
+ * @defaultValue `'sync'`
258
+ */
259
+ mode?: 'sync' | 'async'
260
+ /**
261
+ * Debounce delay in milliseconds before calling `getMatches`.
262
+ * Useful for both async (API calls) and sync (expensive local search) modes.
263
+ * @defaultValue `0` (no debounce)
264
+ * @example `debounceMs: 200` - wait 200ms after last keystroke
265
+ */
266
+ debounceMs?: number
267
+ /**
268
+ * Function that retrieves matches for the current keyword.
269
+ * Use the `debounceMs` option to reduce calls during rapid typing.
270
+ */
271
+ getMatches: GetMatches<TMatch>
272
+ }
273
+
274
+ declare type TypeaheadPickerDefinitionBase<TMatch extends object> = {
275
+ /**
276
+ * RegExp pattern for matching trigger + keyword.
277
+ *
278
+ * If pattern has capture groups: keyword = first capture group (additional groups ignored).
279
+ * If no capture group: keyword = entire match.
280
+ *
281
+ * Can include position anchors like `^` for start-of-block triggers.
282
+ *
283
+ * @example
284
+ * ```ts
285
+ * // Emoji picker - `:` trigger anywhere
286
+ * pattern: /:(\S*)/
287
+ *
288
+ * // Slash commands - `/` only at start of block
289
+ * pattern: /^\/(\w*)/
290
+ *
291
+ * // Mentions - `@` trigger anywhere
292
+ * pattern: /@(\w*)/
293
+ * ```
294
+ */
295
+ pattern: RegExp
296
+ /**
297
+ * Optional delimiter that triggers auto-completion.
298
+ * When typed after a keyword with exactly one exact match, that match auto-inserts.
299
+ * @example `':'` - typing `:joy:` auto-inserts the joy emoji
300
+ */
301
+ autoCompleteWith?: string
302
+ /**
303
+ * Actions to execute when a match is selected.
304
+ * Typically deletes the trigger text and inserts the selected content.
305
+ *
306
+ * @see {@link TypeaheadSelectActionSet}
307
+ */
308
+ actions: Array<TypeaheadSelectActionSet<TMatch>>
309
+ }
310
+
311
+ /**
312
+ * Events that can be sent to the picker via `send()`.
313
+ *
314
+ * - `{type: 'select'}` - Insert the currently selected match
315
+ * - `{type: 'dismiss'}` - Close the picker without inserting
316
+ * - `{type: 'navigate to', index}` - Change the selected match (e.g., on hover or arrow keys)
317
+ *
318
+ * @public
319
+ */
320
+ export declare type TypeaheadPickerEvent =
321
+ | {
322
+ type: 'select'
323
+ }
324
+ | {
325
+ type: 'dismiss'
326
+ }
327
+ | {
328
+ type: 'navigate to'
329
+ index: number
330
+ }
331
+
332
+ /**
333
+ * Possible states for the picker, used with `snapshot.matches()`.
334
+ *
335
+ * Top-level states:
336
+ * - `'idle'` - No trigger pattern detected, picker is inactive
337
+ * - `'active'` - Trigger detected, picker is active (use nested states for specifics)
338
+ *
339
+ * Nested active states (use object syntax):
340
+ * - `{active: 'loading'}` - Waiting for async matches (no results yet)
341
+ * - `{active: 'no matches'}` - No results found
342
+ * - `{active: 'showing matches'}` - Displaying results
343
+ *
344
+ * Nested substates (for background loading without flicker):
345
+ * - `{active: {'no matches': 'loading'}}` - No results, fetching in background
346
+ * - `{active: {'showing matches': 'loading'}}` - Displaying results, fetching in background
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * if (picker.snapshot.matches('idle')) return null
351
+ * if (picker.snapshot.matches({active: 'loading'})) return <Spinner />
352
+ * if (picker.snapshot.matches({active: 'showing matches'})) return <MatchList />
353
+ *
354
+ * // Optional: show subtle refresh indicator
355
+ * const isRefreshing = picker.snapshot.matches({active: {'showing matches': 'loading'}})
356
+ * ```
357
+ *
358
+ * @public
359
+ */
360
+ export declare type TypeaheadPickerState =
361
+ | 'idle'
362
+ | 'active'
363
+ | {
364
+ active: 'loading'
365
+ }
366
+ | {
367
+ active: 'no matches'
368
+ }
369
+ | {
370
+ active: {
371
+ 'no matches': 'loading'
372
+ }
373
+ }
374
+ | {
375
+ active: 'showing matches'
376
+ }
377
+ | {
378
+ active: {
379
+ 'showing matches': 'loading'
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Action function that runs when a match is selected.
385
+ * Returns an array of behavior actions to execute (e.g., delete trigger text, insert content).
386
+ *
387
+ * @example
388
+ * ```ts
389
+ * const insertEmoji: TypeaheadSelectActionSet<EmojiMatch> = (
390
+ * {event},
391
+ * ) => [
392
+ * raise({type: 'delete', at: event.patternSelection}),
393
+ * raise({type: 'insert.text', text: event.match.emoji}),
394
+ * ]
395
+ * ```
396
+ *
397
+ * @public
398
+ */
399
+ export declare type TypeaheadSelectActionSet<TMatch> = BehaviorActionSet<
400
+ TypeaheadSelectEvent<TMatch>,
401
+ true
402
+ >
403
+
404
+ /**
405
+ * Event passed to typeahead select action sets.
406
+ *
407
+ * @public
408
+ */
409
+ export declare type TypeaheadSelectEvent<TMatch> = {
410
+ type: 'typeahead.select'
411
+ /** The match that was selected */
412
+ match: TMatch
413
+ /** The extracted keyword (e.g., `joy` from `:joy`) */
414
+ keyword: string
415
+ /** Selection range covering the full pattern match (e.g., `:joy`) for replacement */
416
+ patternSelection: NonNullable<EditorSelection>
417
+ }
418
+
419
+ /**
420
+ * React hook that activates a typeahead picker and returns its current state.
421
+ *
422
+ * Call inside a component rendered within an `EditorProvider`.
423
+ * The picker automatically monitors the editor for trigger patterns.
424
+ *
425
+ * @example
426
+ * ```tsx
427
+ * function MentionPickerUI() {
428
+ * const picker = useTypeaheadPicker(mentionPickerDefinition)
429
+ *
430
+ * if (picker.snapshot.matches('idle')) return null
431
+ * if (picker.snapshot.matches({active: 'loading'})) return <Spinner />
432
+ * if (picker.snapshot.matches({active: 'no matches'})) return <NoResults />
433
+ *
434
+ * const {matches, selectedIndex} = picker.snapshot.context
435
+ *
436
+ * return (
437
+ * <ul>
438
+ * {matches.map((match, index) => (
439
+ * <li
440
+ * key={match.key}
441
+ * aria-selected={index === selectedIndex}
442
+ * onMouseEnter={() => picker.send({type: 'navigate to', index})}
443
+ * onClick={() => picker.send({type: 'select'})}
444
+ * >
445
+ * {match.name}
446
+ * </li>
447
+ * ))}
448
+ * </ul>
449
+ * )
450
+ * }
451
+ * ```
452
+ *
453
+ * @public
454
+ */
455
+ export declare function useTypeaheadPicker<TMatch extends object>(
456
+ definition: TypeaheadPickerDefinition<TMatch>,
457
+ ): TypeaheadPicker<TMatch>
458
+
459
+ export {}