@workerdeck/ui 0.6.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/build/index.d.mts +927 -0
  4. package/build/index.mjs +5236 -0
  5. package/build/index.mjs.map +1 -0
  6. package/package.json +73 -0
  7. package/src/components/agent/Composer.tsx +139 -0
  8. package/src/components/agent/Conversation.tsx +59 -0
  9. package/src/components/agent/FileCard.tsx +50 -0
  10. package/src/components/agent/Loader.tsx +21 -0
  11. package/src/components/agent/Message.tsx +44 -0
  12. package/src/components/agent/ModelSelect.tsx +87 -0
  13. package/src/components/agent/PermissionModeSelect.tsx +94 -0
  14. package/src/components/agent/PermissionPrompt.tsx +52 -0
  15. package/src/components/agent/QuestionPrompt.tsx +193 -0
  16. package/src/components/agent/Reasoning.tsx +58 -0
  17. package/src/components/agent/Response.tsx +31 -0
  18. package/src/components/agent/SessionList.tsx +93 -0
  19. package/src/components/agent/SessionPanel.tsx +149 -0
  20. package/src/components/agent/StatusBar.tsx +140 -0
  21. package/src/components/agent/ToolCallCard.tsx +94 -0
  22. package/src/components/agent/Transcript.tsx +114 -0
  23. package/src/components/agent/status.ts +16 -0
  24. package/src/components/prompt-area/animated-placeholder.tsx +42 -0
  25. package/src/components/prompt-area/clipboard-helpers.ts +206 -0
  26. package/src/components/prompt-area/cursor-helpers.ts +244 -0
  27. package/src/components/prompt-area/dom-helpers.ts +721 -0
  28. package/src/components/prompt-area/file-strip.tsx +250 -0
  29. package/src/components/prompt-area/html-to-markdown.ts +278 -0
  30. package/src/components/prompt-area/image-strip.tsx +49 -0
  31. package/src/components/prompt-area/index.ts +23 -0
  32. package/src/components/prompt-area/prompt-area-engine.ts +705 -0
  33. package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
  34. package/src/components/prompt-area/prompt-area.tsx +375 -0
  35. package/src/components/prompt-area/remove-button.tsx +37 -0
  36. package/src/components/prompt-area/segment-helpers.ts +62 -0
  37. package/src/components/prompt-area/trigger-popover.tsx +139 -0
  38. package/src/components/prompt-area/trigger-presets.ts +143 -0
  39. package/src/components/prompt-area/types.ts +360 -0
  40. package/src/components/prompt-area/use-markdown-mode.ts +113 -0
  41. package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
  42. package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
  43. package/src/components/prompt-area/use-prompt-area.ts +1507 -0
  44. package/src/components/prompt-area/use-trigger-search.ts +115 -0
  45. package/src/components/ui/AlertDialog.tsx +56 -0
  46. package/src/components/ui/Badge.tsx +42 -0
  47. package/src/components/ui/Button.tsx +47 -0
  48. package/src/components/ui/Card.tsx +29 -0
  49. package/src/components/ui/CodeBlock.tsx +31 -0
  50. package/src/components/ui/CopyButton.tsx +28 -0
  51. package/src/components/ui/Input.tsx +20 -0
  52. package/src/components/ui/ProgressRing.tsx +49 -0
  53. package/src/components/ui/Select.tsx +80 -0
  54. package/src/components/ui/Sonner.tsx +22 -0
  55. package/src/components/ui/Spinner.tsx +6 -0
  56. package/src/components/ui/Textarea.tsx +21 -0
  57. package/src/components/ui/Tooltip.tsx +34 -0
  58. package/src/index.ts +99 -0
  59. package/src/lib/format.ts +67 -0
  60. package/src/lib/utils.ts +33 -0
  61. package/src/styles/theme.css +413 -0
@@ -0,0 +1,375 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import type { PromptAreaProps, PromptAreaHandle } from './types.ts'
6
+ import { usePromptArea } from './use-prompt-area.ts'
7
+ import { BLUR_DELAY_MS } from './use-prompt-area-events.ts'
8
+ import { TriggerPopover } from './trigger-popover.tsx'
9
+ import { AnimatedPlaceholder } from './animated-placeholder.tsx'
10
+ import { ImageStrip } from './image-strip.tsx'
11
+ import { FileStrip } from './file-strip.tsx'
12
+
13
+ /**
14
+ * PromptArea - A lightweight rich text input with trigger support.
15
+ *
16
+ * Uses contentEditable to support inline chips (immutable pills) for
17
+ * mentions, commands, and other triggered tokens. Each trigger character
18
+ * can be configured to show a dropdown or fire a callback.
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * const [segments, setSegments] = useState<Segment[]>([])
23
+ *
24
+ * <PromptArea
25
+ * value={segments}
26
+ * onChange={setSegments}
27
+ * triggers={[
28
+ * { char: '@', position: 'any', mode: 'dropdown', onSearch: searchUsers },
29
+ * { char: '/', position: 'start', mode: 'dropdown', onSearch: searchCommands },
30
+ * { char: '#', position: 'any', mode: 'dropdown', onSearch: searchTags },
31
+ * ]}
32
+ * placeholder="Type a message..."
33
+ * onSubmit={handleSubmit}
34
+ * autoGrow
35
+ * />
36
+ * ```
37
+ */
38
+ export function PromptArea({
39
+ value,
40
+ onChange,
41
+ triggers,
42
+ placeholder,
43
+ className,
44
+ disabled = false,
45
+ markdown,
46
+ normalizeBullets,
47
+ onSubmit,
48
+ onEscape,
49
+ onChipClick,
50
+ onChipAdd,
51
+ onChipDelete,
52
+ onLinkClick,
53
+ onPaste,
54
+ onUndo,
55
+ onRedo,
56
+ minHeight = 80,
57
+ maxHeight,
58
+ autoFocus = false,
59
+ autoGrow = false,
60
+ 'aria-label': ariaLabel,
61
+ 'data-test-id': dataTestId,
62
+ images = [],
63
+ imagePosition = 'above',
64
+ onImagePaste,
65
+ onImageRemove,
66
+ onImageClick,
67
+ files = [],
68
+ filePosition = 'above',
69
+ onFileRemove,
70
+ onFileClick,
71
+ onKeyDown,
72
+ onBlur,
73
+ onRawPaste,
74
+ submitOnEnter,
75
+ spellCheck,
76
+ maxLength,
77
+ 'aria-describedby': ariaDescribedBy,
78
+ ref,
79
+ }: PromptAreaProps & { ref?: React.Ref<PromptAreaHandle> }) {
80
+ const {
81
+ editorRef,
82
+ activeTrigger,
83
+ suggestions,
84
+ suggestionsLoading,
85
+ suggestionsError,
86
+ selectedSuggestionIndex,
87
+ handleInput,
88
+ handleKeyDown,
89
+ handleClick,
90
+ handleMouseDown,
91
+ selectSuggestion,
92
+ dismissTrigger,
93
+ handle,
94
+ triggerRect,
95
+ eventHandlers,
96
+ } = usePromptArea({
97
+ value,
98
+ onChange,
99
+ triggers,
100
+ disabled,
101
+ onSubmit,
102
+ onEscape,
103
+ onChipClick,
104
+ onChipAdd,
105
+ onChipDelete,
106
+ onLinkClick,
107
+ onPaste,
108
+ onRawPaste,
109
+ onUndo,
110
+ onRedo,
111
+ onImagePaste,
112
+ markdown,
113
+ normalizeBullets,
114
+ submitOnEnter,
115
+ maxLength,
116
+ })
117
+
118
+ // Expose imperative handle via ref
119
+ useImperativeHandle(ref, () => handle, [handle])
120
+
121
+ // Auto-focus on mount
122
+ useEffect(() => {
123
+ if (autoFocus) {
124
+ editorRef.current?.focus()
125
+ }
126
+ }, [autoFocus, editorRef])
127
+
128
+ // -----------------------------------------------------------------------
129
+ // Auto-grow: expand on focus/input, shrink on blur
130
+ // -----------------------------------------------------------------------
131
+
132
+ const [isFocused, setIsFocused] = useState(false)
133
+ const [editorHeight, setEditorHeight] = useState<number | undefined>(undefined)
134
+
135
+ const syncHeight = useCallback(() => {
136
+ const el = editorRef.current
137
+ if (!el) return
138
+ // Temporarily set height to auto so scrollHeight reflects true content height
139
+ el.style.height = 'auto'
140
+ const contentHeight = el.scrollHeight
141
+ el.style.height = `${contentHeight}px`
142
+ setEditorHeight(contentHeight)
143
+ }, [editorRef])
144
+
145
+ const handleFocus = useCallback(() => {
146
+ if (!autoGrow) return
147
+ setIsFocused(true)
148
+ syncHeight()
149
+ }, [autoGrow, syncHeight])
150
+
151
+ const handleBlurWithShrink = useCallback(() => {
152
+ eventHandlers.onBlur()
153
+ if (!autoGrow) return
154
+ setTimeout(() => {
155
+ const editor = editorRef.current
156
+ if (!editor) return
157
+ // Only shrink if focus truly left the component
158
+ const activeEl = document.activeElement
159
+ if (activeEl && editor.parentElement?.contains(activeEl)) return
160
+ setIsFocused(false)
161
+ setEditorHeight(undefined)
162
+ }, BLUR_DELAY_MS)
163
+ }, [eventHandlers, autoGrow, editorRef])
164
+
165
+ const handleInputWithGrow = useCallback(() => {
166
+ handleInput()
167
+ if (autoGrow && isFocused) {
168
+ syncHeight()
169
+ }
170
+ }, [handleInput, autoGrow, isFocused, syncHeight])
171
+
172
+ // Re-measure on value changes (chip insertion, undo/redo, programmatic updates)
173
+ useEffect(() => {
174
+ if (autoGrow && isFocused) {
175
+ requestAnimationFrame(() => syncHeight())
176
+ }
177
+ }, [value, autoGrow, isFocused, syncHeight])
178
+
179
+ // -----------------------------------------------------------------------
180
+ // Overflow indicator: detect when collapsed content is clipped
181
+ // -----------------------------------------------------------------------
182
+
183
+ const [hasOverflow, setHasOverflow] = useState(false)
184
+ const overflowTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
185
+
186
+ useEffect(() => {
187
+ if (!autoGrow) return
188
+
189
+ const checkOverflow = () => {
190
+ if (isFocused) {
191
+ setHasOverflow(false)
192
+ return
193
+ }
194
+ const el = editorRef.current
195
+ if (!el) return
196
+ setHasOverflow(el.scrollHeight > el.clientHeight)
197
+ }
198
+
199
+ // Delay the check so the CSS height transition (150ms) finishes first;
200
+ // on initial mount there is no transition so the check still runs quickly.
201
+ const delay = isFocused ? 0 : 160
202
+ overflowTimerRef.current = setTimeout(checkOverflow, delay)
203
+ return () => {
204
+ if (overflowTimerRef.current !== null) {
205
+ clearTimeout(overflowTimerRef.current)
206
+ }
207
+ }
208
+ }, [autoGrow, isFocused, value, editorRef])
209
+
210
+ // -----------------------------------------------------------------------
211
+ // Compute editor style
212
+ // -----------------------------------------------------------------------
213
+
214
+ const editorStyle = useMemo((): React.CSSProperties => {
215
+ if (!autoGrow) {
216
+ const style: React.CSSProperties = { minHeight: `${minHeight}px` }
217
+ if (maxHeight) {
218
+ style.maxHeight = `${maxHeight}px`
219
+ style.overflowY = 'auto'
220
+ }
221
+ return style
222
+ }
223
+ return {
224
+ height: isFocused && editorHeight ? `${editorHeight}px` : `${minHeight}px`,
225
+ minHeight: `${minHeight}px`,
226
+ // Respect an explicit maxHeight; otherwise fall back to a viewport-relative
227
+ // cap so the editor never grows past the screen.
228
+ maxHeight: maxHeight ? `${maxHeight}px` : '70dvh',
229
+ overflowY: isFocused ? 'auto' : 'hidden',
230
+ // `min-height` is eased so consumers that animate it (e.g. the compact
231
+ // prompt area's collapse/expand) morph smoothly instead of snapping.
232
+ transition: 'height 150ms ease-out, min-height 240ms cubic-bezier(0.33, 1, 0.68, 1)',
233
+ }
234
+ }, [autoGrow, minHeight, maxHeight, isFocused, editorHeight])
235
+
236
+ // Run a consumer's onKeyDown first; if it calls preventDefault, skip all of
237
+ // PromptArea's built-in key handling (submit, trigger nav, etc.).
238
+ const handleKeyDownCombined = useCallback(
239
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
240
+ onKeyDown?.(e)
241
+ if (e.defaultPrevented) return
242
+ handleKeyDown(e)
243
+ },
244
+ [onKeyDown, handleKeyDown],
245
+ )
246
+
247
+ // Forward blur to the consumer (with relatedTarget) alongside the internal
248
+ // trigger-dismiss / auto-grow-shrink handling.
249
+ const handleBlurCombined = useCallback(
250
+ (e: React.FocusEvent<HTMLDivElement>) => {
251
+ onBlur?.(e)
252
+ // handleBlurWithShrink already calls eventHandlers.onBlur() and only
253
+ // shrinks when autoGrow is on, so it covers both modes.
254
+ handleBlurWithShrink()
255
+ },
256
+ [onBlur, handleBlurWithShrink],
257
+ )
258
+
259
+ const isEmpty =
260
+ value.length === 0 || (value.length === 1 && value[0].type === 'text' && value[0].text === '')
261
+
262
+ const imageStrip =
263
+ images.length > 0 ? (
264
+ <ImageStrip
265
+ images={images}
266
+ onRemove={onImageRemove}
267
+ onClick={onImageClick}
268
+ className={imagePosition === 'above' ? 'pb-2' : 'pt-2'}
269
+ />
270
+ ) : null
271
+
272
+ const fileStrip =
273
+ files.length > 0 ? (
274
+ <FileStrip
275
+ files={files}
276
+ onRemove={onFileRemove}
277
+ onClick={onFileClick}
278
+ className={filePosition === 'above' ? 'pb-2' : 'pt-2'}
279
+ />
280
+ ) : null
281
+
282
+ // Typography (font-size/line-height) lives on the container, not the editor, so
283
+ // it cascades to the editor AND the placeholder overlays — and a consumer can
284
+ // override all three at once via `className` (e.g. `text-base leading-6`).
285
+ return (
286
+ <div className={cn('prompt-area-container relative text-sm leading-relaxed', className)}>
287
+ {imagePosition === 'above' && imageStrip}
288
+ {filePosition === 'above' && fileStrip}
289
+
290
+ {/* Editor + placeholder wrapper */}
291
+ <div className="relative">
292
+ <div
293
+ ref={editorRef}
294
+ contentEditable={!disabled}
295
+ suppressContentEditableWarning
296
+ role="textbox"
297
+ aria-label={ariaLabel ?? 'Text input'}
298
+ aria-multiline="true"
299
+ aria-disabled={disabled || undefined}
300
+ aria-describedby={ariaDescribedBy}
301
+ data-test-id={dataTestId}
302
+ spellCheck={spellCheck}
303
+ className={cn(
304
+ 'prompt-area-editor',
305
+ 'w-full min-w-0 break-words whitespace-pre-wrap outline-none',
306
+ disabled && 'cursor-not-allowed opacity-50',
307
+ )}
308
+ style={editorStyle}
309
+ onFocus={handleFocus}
310
+ onInput={autoGrow ? handleInputWithGrow : handleInput}
311
+ onKeyDown={handleKeyDownCombined}
312
+ onMouseDown={handleMouseDown}
313
+ onClick={handleClick}
314
+ onPaste={eventHandlers.onPaste}
315
+ onCopy={eventHandlers.onCopy}
316
+ onCut={eventHandlers.onCut}
317
+ onDrop={eventHandlers.onDrop}
318
+ onDragOver={eventHandlers.onDragOver}
319
+ onCompositionStart={eventHandlers.onCompositionStart}
320
+ onCompositionEnd={eventHandlers.onCompositionEnd}
321
+ onBlur={handleBlurCombined}
322
+ />
323
+
324
+ {/* Overflow gradient indicator – visible when auto-grow is collapsed and content is clipped */}
325
+ {autoGrow && hasOverflow && !isFocused && (
326
+ <div
327
+ aria-hidden="true"
328
+ className="pointer-events-auto absolute right-0 bottom-0 left-0 cursor-pointer"
329
+ style={{ height: '32px' }}
330
+ onClick={() => editorRef.current?.focus()}>
331
+ <div
332
+ className="h-full w-full"
333
+ style={{
334
+ background:
335
+ 'linear-gradient(to bottom, transparent, color-mix(in srgb, var(--prompt-area-surface, var(--background)) 80%, transparent), var(--prompt-area-surface, var(--background)))',
336
+ }}
337
+ />
338
+ </div>
339
+ )}
340
+
341
+ {/* Placeholder overlay */}
342
+ {isEmpty &&
343
+ placeholder &&
344
+ (Array.isArray(placeholder) ? (
345
+ <AnimatedPlaceholder texts={placeholder} />
346
+ ) : (
347
+ <div
348
+ className="pointer-events-none absolute top-0 left-0 select-none"
349
+ style={{ color: 'var(--prompt-area-placeholder, var(--muted-foreground))' }}
350
+ aria-hidden="true">
351
+ {placeholder}
352
+ </div>
353
+ ))}
354
+ </div>
355
+
356
+ {filePosition === 'below' && fileStrip}
357
+ {imagePosition === 'below' && imageStrip}
358
+
359
+ {/* Trigger suggestion popover */}
360
+ {activeTrigger && activeTrigger.config.mode === 'dropdown' && (
361
+ <TriggerPopover
362
+ suggestions={suggestions}
363
+ loading={suggestionsLoading}
364
+ error={suggestionsError}
365
+ emptyMessage={activeTrigger.config.emptyMessage}
366
+ selectedIndex={selectedSuggestionIndex}
367
+ onSelect={selectSuggestion}
368
+ onDismiss={dismissTrigger}
369
+ triggerRect={triggerRect}
370
+ triggerChar={activeTrigger.config.char}
371
+ />
372
+ )}
373
+ </div>
374
+ )
375
+ }
@@ -0,0 +1,37 @@
1
+ import { cn } from '../../lib/utils.ts'
2
+
3
+ type RemoveButtonProps = {
4
+ onClick: () => void
5
+ label: string
6
+ className?: string
7
+ }
8
+
9
+ export function RemoveButton({ onClick, label, className }: RemoveButtonProps) {
10
+ return (
11
+ <button
12
+ type="button"
13
+ onClick={(e) => {
14
+ e.stopPropagation()
15
+ onClick()
16
+ }}
17
+ className={cn(
18
+ 'absolute top-0.5 right-0.5 grid h-3.5 w-3.5 cursor-pointer place-items-center',
19
+ 'rounded-full bg-black/60 text-white hover:bg-black/80 dark:bg-white/60 dark:text-black dark:hover:bg-white/80',
20
+ 'transition-colors',
21
+ className,
22
+ )}
23
+ aria-label={label}>
24
+ <svg
25
+ width="8"
26
+ height="8"
27
+ viewBox="0 0 10 10"
28
+ fill="none"
29
+ stroke="currentColor"
30
+ strokeWidth="1.5"
31
+ strokeLinecap="round">
32
+ <line x1="2.75" y1="2.75" x2="7.25" y2="7.25" />
33
+ <line x1="7.25" y1="2.75" x2="2.75" y2="7.25" />
34
+ </svg>
35
+ </button>
36
+ )
37
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Convenience helpers for creating and inspecting Segments.
3
+ *
4
+ * These reduce boilerplate when building AI chat UIs that work with the
5
+ * PromptArea document model.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { text, chip, isSegmentsEmpty, segmentsToPlainText } from './segment-helpers.ts'
10
+ *
11
+ * const greeting = [text('Hello '), chip({ trigger: '@', value: 'u1', displayText: 'Alice' })]
12
+ * isSegmentsEmpty(greeting) // false
13
+ * segmentsToPlainText(greeting) // "Hello @Alice"
14
+ * ```
15
+ */
16
+
17
+ import type { Segment, TextSegment, ChipSegment } from './types.ts'
18
+ import { segmentsToPlainText, plainTextToSegments } from './prompt-area-engine.ts'
19
+
20
+ // Re-export serialization utilities so consumers have a single import.
21
+ export { segmentsToPlainText, plainTextToSegments }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Factories
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Create a text segment. */
28
+ export function text(value: string): TextSegment {
29
+ return { type: 'text', text: value }
30
+ }
31
+
32
+ /** Create a chip segment. */
33
+ export function chip(opts: Omit<ChipSegment, 'type'>): ChipSegment {
34
+ return { type: 'chip', ...opts }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Predicates
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Returns `true` when the segment array is empty or contains only whitespace text. */
42
+ export function isSegmentsEmpty(segments: Segment[]): boolean {
43
+ if (segments.length === 0) return true
44
+ return segments.every((seg) => seg.type === 'text' && seg.text.trim() === '')
45
+ }
46
+
47
+ /** Returns `true` when the segment array contains at least one chip. */
48
+ export function hasChips(segments: Segment[]): boolean {
49
+ return segments.some((seg) => seg.type === 'chip')
50
+ }
51
+
52
+ /** Extracts all chip segments from a segment array. */
53
+ export function getChips(segments: Segment[]): ChipSegment[] {
54
+ return segments.filter((seg): seg is ChipSegment => seg.type === 'chip')
55
+ }
56
+
57
+ /** Extracts chips matching a specific trigger character. */
58
+ export function getChipsByTrigger(segments: Segment[], trigger: string): ChipSegment[] {
59
+ return segments.filter(
60
+ (seg): seg is ChipSegment => seg.type === 'chip' && seg.trigger === trigger,
61
+ )
62
+ }
@@ -0,0 +1,139 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import type { TriggerSuggestion } from './types.ts'
6
+
7
+ type TriggerPopoverProps = {
8
+ suggestions: TriggerSuggestion[]
9
+ loading: boolean
10
+ error?: string | null
11
+ emptyMessage?: string
12
+ selectedIndex: number
13
+ onSelect: (suggestion: TriggerSuggestion) => void
14
+ onDismiss: () => void
15
+ triggerRect: DOMRect | null
16
+ triggerChar: string
17
+ }
18
+
19
+ // Single source of truth for the popover height: drives both the flip-above
20
+ // calculation and the maxHeight style.
21
+ const POPOVER_MAX_HEIGHT = 240
22
+
23
+ /**
24
+ * Floating popover that displays trigger suggestions.
25
+ * Positioned relative to the trigger character location in the editor.
26
+ */
27
+ export function TriggerPopover({
28
+ suggestions,
29
+ loading,
30
+ error,
31
+ emptyMessage,
32
+ selectedIndex,
33
+ onSelect,
34
+ onDismiss,
35
+ triggerRect,
36
+ triggerChar,
37
+ }: TriggerPopoverProps) {
38
+ const popoverRef = useRef<HTMLDivElement>(null)
39
+ const selectedRef = useRef<HTMLButtonElement>(null)
40
+
41
+ // Scroll selected item into view
42
+ useEffect(() => {
43
+ selectedRef.current?.scrollIntoView({ block: 'nearest' })
44
+ }, [selectedIndex])
45
+
46
+ // Click outside to dismiss
47
+ useEffect(() => {
48
+ const handleClickOutside = (e: MouseEvent) => {
49
+ const target = e.target
50
+ if (popoverRef.current && target instanceof Node && !popoverRef.current.contains(target)) {
51
+ onDismiss()
52
+ }
53
+ }
54
+ document.addEventListener('mousedown', handleClickOutside)
55
+ return () => document.removeEventListener('mousedown', handleClickOutside)
56
+ }, [onDismiss])
57
+
58
+ if (!triggerRect) return null
59
+ if (suggestions.length === 0 && !loading && !error && !emptyMessage) return null
60
+
61
+ // Position the popover relative to the trigger character, clamped to the
62
+ // viewport. Flip above the trigger when there isn't enough room below, so the
63
+ // suggestion list stays on-screen near the bottom edge.
64
+ const popoverMaxWidth = Math.min(320, window.innerWidth - 16)
65
+ const left = Math.min(triggerRect.left, window.innerWidth - popoverMaxWidth - 8)
66
+ const spaceBelow = window.innerHeight - triggerRect.bottom
67
+ const positionAbove = spaceBelow < POPOVER_MAX_HEIGHT && triggerRect.top > spaceBelow
68
+ const style: React.CSSProperties = {
69
+ position: 'fixed',
70
+ left: `${Math.max(8, left)}px`,
71
+ zIndex: 50,
72
+ maxWidth: `${popoverMaxWidth}px`,
73
+ maxHeight: `${POPOVER_MAX_HEIGHT}px`,
74
+ ...(positionAbove
75
+ ? { bottom: `${window.innerHeight - triggerRect.top + 4}px` }
76
+ : { top: `${triggerRect.bottom + 4}px` }),
77
+ }
78
+
79
+ return (
80
+ <div
81
+ ref={popoverRef}
82
+ className={cn(
83
+ 'min-w-[200px] overflow-y-auto',
84
+ 'bg-surface rounded-xl border p-2 shadow-md',
85
+ 'animate-in fade-in-0 zoom-in-95',
86
+ )}
87
+ style={style}
88
+ role="listbox"
89
+ aria-label={`${triggerChar} suggestions`}>
90
+ {loading ? (
91
+ <div
92
+ role="option"
93
+ aria-selected={false}
94
+ className="text-muted-foreground px-3 py-2 text-sm">
95
+ Loading suggestions...
96
+ </div>
97
+ ) : error ? (
98
+ <div role="option" aria-selected={false} className="text-destructive px-3 py-2 text-sm">
99
+ {error}
100
+ </div>
101
+ ) : suggestions.length === 0 && emptyMessage ? (
102
+ <div
103
+ role="option"
104
+ aria-selected={false}
105
+ className="text-muted-foreground px-3 py-2 text-sm">
106
+ {emptyMessage}
107
+ </div>
108
+ ) : (
109
+ suggestions.map((suggestion, index) => (
110
+ <button
111
+ key={suggestion.value}
112
+ ref={index === selectedIndex ? selectedRef : undefined}
113
+ type="button"
114
+ role="option"
115
+ aria-selected={index === selectedIndex}
116
+ className={cn(
117
+ 'text-foreground flex w-full items-start gap-2 rounded-lg px-3 py-2 text-left text-sm',
118
+ 'hover:bg-surface-hover cursor-pointer transition-colors',
119
+ index === selectedIndex && 'bg-surface-hover',
120
+ )}
121
+ onMouseDown={(e) => {
122
+ e.preventDefault() // Prevent blur on the editor
123
+ onSelect(suggestion)
124
+ }}>
125
+ {suggestion.icon && <span className="mt-0.5 shrink-0">{suggestion.icon}</span>}
126
+ <div className="min-w-0 flex-1">
127
+ <div className="truncate font-medium">{suggestion.label}</div>
128
+ {suggestion.description && (
129
+ <div className="text-muted-foreground truncate text-xs">
130
+ {suggestion.description}
131
+ </div>
132
+ )}
133
+ </div>
134
+ </button>
135
+ ))
136
+ )}
137
+ </div>
138
+ )
139
+ }