feat(ui): update context menu (#4362)

* feat(ui): update context menu

* fix(user-input): clear mention state on submit

Programmatic plusMenuRef.close() sets the dropdown's internal open=false but
Radix doesn't fire onOpenChange for controlled changes, so handlePlusMenuClose
never ran and mentionRangeRef stayed truthy after submitting a message with
an active @mention. That caused the keydown handler to keep intercepting
ArrowUp/ArrowDown/Tab post-submit, breaking the "edit last queued message"
ArrowUp shortcut until the user typed again.

Clear mentionRangeRef and mentionQuery inline alongside the close() call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ui): widen mention/plus dropdown to 420px

Long resource names were truncating in a 320px-wide menu. Bump to 420px and
cap with max-w on viewport so it can't overflow on small screens. Overrides
the emcn DropdownMenuContent's default max-w-[220px] via twMerge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ui): trim mention/plus dropdown width to 360px

420px felt too wide; 360px gives long resource names enough room without
dominating the input area.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tweak context menu widths

* address greptile review on PR #4362

- plus-menu-dropdown: drop folder type from the flat mention list (folders organize
  resources but aren't an insertable mention target — the nested rendering already
  excludes them).
- user-input: fold Enter into the mention-mode keydown guard so Enter confirms the
  highlighted resource instead of submitting the form. Falls through to the normal
  Enter-submit path when no match is highlighted (Tab keeps prior behavior).
- dropdown-menu wrapper: drop the misleading cast on the spread — runtime accepts
  onOpenAutoFocus regardless and the cast was hiding any future props added to
  DropdownMenuContentProps.
- plus-menu-dropdown: trim mention-mode + submenu widths to 300px (320px clipped on
  the narrower copilot panel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-04-30 17:33:22 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 91092842e3
commit 49c12011dc
5 changed files with 284 additions and 164 deletions
@@ -34,7 +34,10 @@ export type WindowWithSpeech = Window & {
}
export interface PlusMenuHandle {
open: (anchor?: { left: number; top: number }) => void
open: (anchor?: { left: number; top: number }, options?: { mention?: boolean }) => void
close: () => void
moveActive: (delta: number) => void
selectActive: () => boolean
}
export const TEXTAREA_BASE_CLASSES = cn(
@@ -1,7 +1,6 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Paperclip } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,7 +11,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/emcn'
import { Plus, Sim } from '@/components/emcn/icons'
import { Plus } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import {
buildWorkflowFolderTree,
@@ -28,18 +27,20 @@ export type AvailableResourceGroup = ReturnType<typeof useAvailableResources>[nu
interface PlusMenuDropdownProps {
availableResources: AvailableResourceGroup[]
onResourceSelect: (resource: MothershipResource) => void
onFileSelect: () => void
onClose: () => void
textareaRef: React.RefObject<HTMLTextAreaElement | null>
pendingCursorRef: React.MutableRefObject<number | null>
/** When in mention mode the dropdown hides its search input and uses this query for filtering. */
mentionQuery?: string
}
export const PlusMenuDropdown = React.memo(
React.forwardRef<PlusMenuHandle, PlusMenuDropdownProps>(function PlusMenuDropdown(
{ availableResources, onResourceSelect, onFileSelect, onClose, textareaRef, pendingCursorRef },
{ availableResources, onResourceSelect, onClose, textareaRef, pendingCursorRef, mentionQuery },
ref
) {
const [open, setOpen] = useState(false)
const [isMention, setIsMention] = useState(false)
const [search, setSearch] = useState('')
const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
@@ -47,20 +48,26 @@ export const PlusMenuDropdown = React.memo(
const searchRef = useRef<HTMLInputElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const doOpen = useCallback((anchor?: { left: number; top: number }) => {
if (anchor) {
setAnchorPos(anchor)
} else {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
setAnchorPos({ left: rect.left, top: rect.top })
}
setOpen(true)
setSearch('')
setActiveIndex(0)
}, [])
const doOpen = useCallback(
(anchor?: { left: number; top: number }, options?: { mention?: boolean }) => {
if (anchor) {
setAnchorPos(anchor)
} else {
const rect = buttonRef.current?.getBoundingClientRect()
if (!rect) return
setAnchorPos({ left: rect.left, top: rect.top })
}
setIsMention(!!options?.mention)
setOpen(true)
setSearch('')
setActiveIndex(0)
},
[]
)
React.useImperativeHandle(ref, () => ({ open: doOpen }), [doOpen])
const doClose = useCallback(() => {
setOpen(false)
}, [])
const workflowTree = useMemo(() => {
const workflowGroup = availableResources.find((g) => g.type === 'workflow')
@@ -69,12 +76,33 @@ export const PlusMenuDropdown = React.memo(
}, [availableResources])
const filteredItems = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return null
return availableResources.flatMap(({ type, items }) =>
const rawQuery = isMention ? (mentionQuery ?? '') : search
const q = rawQuery.toLowerCase().trim()
// In mention mode always render a flat filtered list — empty query = show everything.
if (!isMention && !q) return null
// Folders organize resources but aren't a valid mention/insertable target — drop them
// from the flat list (matches the nested rendering, which also excludes them).
const flatGroups = availableResources.filter(({ type }) => type !== 'folder')
if (isMention && !q) {
return flatGroups.flatMap(({ type, items }) => items.map((item) => ({ type, item })))
}
return flatGroups.flatMap(({ type, items }) =>
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
)
}, [search, availableResources])
}, [isMention, mentionQuery, search, availableResources])
const filteredItemsRef = useRef(filteredItems)
filteredItemsRef.current = filteredItems
const activeIndexRef = useRef(activeIndex)
activeIndexRef.current = activeIndex
const isMentionRef = useRef(isMention)
isMentionRef.current = isMention
// Reset highlight to the top whenever the mention query changes so the user always
// sees the best match selected as they type.
useEffect(() => {
if (isMention) setActiveIndex(0)
}, [isMention, mentionQuery])
const handleSelect = (resource: MothershipResource) => {
onResourceSelect(resource)
@@ -83,6 +111,40 @@ export const PlusMenuDropdown = React.memo(
setActiveIndex(0)
}
const handleSelectRef = useRef(handleSelect)
handleSelectRef.current = handleSelect
React.useImperativeHandle(
ref,
() => ({
open: doOpen,
close: doClose,
moveActive: (delta: number) => {
const items = filteredItemsRef.current
if (!items || items.length === 0) return
setActiveIndex((i) => {
const next = i + delta
if (next < 0) return items.length - 1
if (next >= items.length) return 0
return next
})
},
selectActive: () => {
const items = filteredItemsRef.current
if (!items || items.length === 0) return false
const target = items[activeIndexRef.current] ?? items[0]
if (!target) return false
handleSelectRef.current({
type: target.type,
id: target.item.id,
title: target.item.name,
})
return true
},
}),
[doOpen, doClose]
)
// Sync DOM scroll to the keyboard-highlighted filtered row.
useEffect(() => {
if (!filteredItems || filteredItems.length === 0) return
@@ -156,6 +218,13 @@ export const PlusMenuDropdown = React.memo(
textarea.focus()
}
// Radix's FocusScope normally focuses the content on open and traps focus inside.
// Preventing the mount auto-focus keeps the textarea focused AND, because the focus
// trap activates on focusin, the trap stays dormant — typing continues uninterrupted.
const handleOpenAutoFocus = (e: Event) => {
if (isMentionRef.current) e.preventDefault()
}
return (
<>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
@@ -176,86 +245,79 @@ export const PlusMenuDropdown = React.memo(
align='start'
side='top'
sideOffset={8}
className='flex w-[320px] flex-col overflow-hidden'
avoidCollisions={!isMention}
className={cn(
'flex flex-col overflow-hidden',
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
// to its content via the emcn DropdownMenuContent default max-w.
// Mention mode renders resource names directly, so widen for breathing room.
isMention && 'w-[300px] max-w-[calc(100vw-32px)]'
)}
onCloseAutoFocus={handleCloseAutoFocus}
onOpenAutoFocus={handleOpenAutoFocus}
onKeyDown={handleContentKeyDown}
>
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
{!isMention && (
<DropdownMenuSearchInput
ref={searchRef}
placeholder='Search resources...'
value={search}
onChange={(e) => {
setSearch(e.target.value)
setActiveIndex(0)
}}
onKeyDown={handleSearchKeyDown}
/>
)}
<div className='min-h-0 flex-1 overflow-y-auto'>
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
menu FocusScope steal focus from the search input back to the content root. */}
<div hidden={filteredItems !== null}>
<DropdownMenuItem
onClick={() => {
setOpen(false)
onFileSelect()
}}
>
<Paperclip className='h-[14px] w-[14px]' strokeWidth={2} />
<span>Attachments</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Sim className='h-[14px] w-[14px]' fill='currentColor' />
<span>Workspace</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{workflowTree.length > 0 && (
<DropdownMenuSub>
{workflowTree.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
<span>Workflows</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded-[3px] border-[2px]'
style={{
backgroundColor: '#808080',
borderColor: '#80808060',
backgroundClip: 'padding-box',
}}
/>
<span>Workflows</span>
<Icon className='h-[14px] w-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<WorkflowFolderTreeItems nodes={workflowTree} onSelect={handleSelect} />
<DropdownMenuSubContent className='w-[300px] max-w-[calc(100vw-32px)]'>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{availableResources
.filter(({ type }) => type !== 'workflow' && type !== 'folder')
.map(({ type, items }) => {
if (items.length === 0) return null
const config = getResourceConfig(type)
const Icon = config.icon
return (
<DropdownMenuSub key={type}>
<DropdownMenuSubTrigger>
<Icon className='h-[14px] w-[14px]' />
<span>{config.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{items.map((item) => (
<DropdownMenuItem
key={item.id}
onClick={() => {
handleSelect({ type, id: item.id, title: item.name })
}}
>
{config.renderDropdownItem({ item })}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
})}
</div>
{/* Plain buttons, not DropdownMenuItem: mount/unmount must not mutate Radix's
menu Collection, or FocusScope restores focus to the content root. */}
@@ -11,7 +11,9 @@ import {
useRef,
useState,
} from 'react'
import { Paperclip } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip } from '@/components/emcn'
import { useSession } from '@/lib/auth/auth-client'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
@@ -56,50 +58,6 @@ import type { ChatContext } from '@/stores/panel'
export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types'
function getCaretAnchor(
textarea: HTMLTextAreaElement,
caretPos: number
): { left: number; top: number } {
const textareaRect = textarea.getBoundingClientRect()
const style = window.getComputedStyle(textarea)
const mirror = document.createElement('div')
mirror.style.position = 'absolute'
mirror.style.top = '0'
mirror.style.left = '0'
mirror.style.visibility = 'hidden'
mirror.style.whiteSpace = 'pre-wrap'
mirror.style.overflowWrap = 'break-word'
mirror.style.font = style.font
mirror.style.padding = style.padding
mirror.style.border = style.border
mirror.style.width = style.width
mirror.style.lineHeight = style.lineHeight
mirror.style.boxSizing = style.boxSizing
mirror.style.letterSpacing = style.letterSpacing
mirror.style.textTransform = style.textTransform
mirror.style.textIndent = style.textIndent
mirror.style.textAlign = style.textAlign
mirror.textContent = textarea.value.substring(0, caretPos)
const marker = document.createElement('span')
marker.style.display = 'inline-block'
marker.style.width = '0px'
marker.style.padding = '0'
marker.style.border = '0'
mirror.appendChild(marker)
document.body.appendChild(mirror)
const markerRect = marker.getBoundingClientRect()
const mirrorRect = mirror.getBoundingClientRect()
document.body.removeChild(mirror)
return {
left: textareaRect.left + (markerRect.left - mirrorRect.left) - textarea.scrollLeft,
top: textareaRect.top + (markerRect.top - mirrorRect.top) - textarea.scrollTop,
}
}
interface UserInputProps {
defaultValue?: string
draftScopeKey?: string
@@ -344,6 +302,9 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
const wasSendingRef = useRef(false)
const atInsertPosRef = useRef<number | null>(null)
const pendingCursorRef = useRef<number | null>(null)
const mentionRangeRef = useRef<{ start: number; end: number } | null>(null)
const [mentionQuery, setMentionQuery] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useImperativeHandle(
ref,
@@ -389,17 +350,35 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
const textarea = textareaRef.current
if (textarea) {
const currentValue = valueRef.current
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
const insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
const before = currentValue.slice(0, insertAt)
const after = currentValue.slice(insertAt)
const range = mentionRangeRef.current
let before: string
let after: string
let insertText: string
let newPos: number
if (range) {
before = currentValue.slice(0, range.start)
after = currentValue.slice(range.end)
const needsSpaceBefore =
range.start > 0 && !/\s/.test(currentValue.charAt(range.start - 1))
insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
newPos = before.length + insertText.length
} else {
const insertAt = atInsertPosRef.current ?? textarea.selectionStart ?? currentValue.length
const needsSpaceBefore = insertAt > 0 && !/\s/.test(currentValue.charAt(insertAt - 1))
insertText = `${needsSpaceBefore ? ' ' : ''}@${resource.title} `
before = currentValue.slice(0, insertAt)
after = currentValue.slice(insertAt)
newPos = before.length + insertText.length
}
const newValue = `${before}${insertText}${after}`
const newPos = before.length + insertText.length
pendingCursorRef.current = newPos
// Eagerly sync refs so successive drop-handler iterations see the updated position
valueRef.current = newValue
atInsertPosRef.current = newPos
mentionRangeRef.current = null
setMentionQuery(null)
setValue(newValue)
}
@@ -411,6 +390,8 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
const handlePlusMenuClose = useCallback(() => {
atInsertPosRef.current = null
mentionRangeRef.current = null
setMentionQuery(null)
}, [])
const handleFileSelectStable = useCallback(() => {
@@ -554,6 +535,11 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
currentFiles.clearAttachedFiles()
prevSelectedContextsRef.current = []
currentContext.clearContexts()
// Programmatic close() bypasses Radix's onOpenChange, so handlePlusMenuClose won't
// fire — clear mention state inline so ArrowUp etc. aren't intercepted post-submit.
plusMenuRef.current?.close()
mentionRangeRef.current = null
setMentionQuery(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
@@ -562,6 +548,27 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionRangeRef.current && !e.nativeEvent.isComposing) {
if (e.key === 'ArrowDown') {
e.preventDefault()
plusMenuRef.current?.moveActive(1)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
plusMenuRef.current?.moveActive(-1)
return
}
if ((e.key === 'Tab' || e.key === 'Enter') && !e.shiftKey) {
// Confirm the highlighted match if there is one. If no items match, fall
// through so Enter still submits and Tab still does its default thing.
if (plusMenuRef.current?.selectActive()) {
e.preventDefault()
return
}
}
}
if (e.key === 'ArrowUp' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
const isEmpty = valueRef.current.length === 0 && filesRef.current.attachedFiles.length === 0
if (isEmpty && onEditQueuedTailRef.current) {
@@ -653,27 +660,47 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
[handleSubmit, mentionTokensWithContext, value, textareaRef]
)
function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
const getActiveMentionAtRef = useRef(mentionMenu.getActiveMentionQueryAtPosition)
getActiveMentionAtRef.current = mentionMenu.getActiveMentionQueryAtPosition
if (
caret > 0 &&
newValue.charAt(caret - 1) === '@' &&
(caret === 1 || /\s/.test(newValue.charAt(caret - 2)))
) {
const before = newValue.slice(0, caret - 1)
const after = newValue.slice(caret)
const adjusted = `${before}${after}`
setValue(adjusted)
atInsertPosRef.current = caret - 1
const anchor = getCaretAnchor(e.target, caret - 1)
plusMenuRef.current?.open(anchor)
return
}
const syncMentionState = useCallback(
(_textarea: HTMLTextAreaElement, text: string, caret: number) => {
const active = getActiveMentionAtRef.current(caret, text)
// Treat any whitespace inside the query as a closer — typing a space
// after `@foo` should leave the raw `@foo` text and dismiss the menu.
const isOpenable = active && !/\s/.test(active.query)
if (!isOpenable) {
if (mentionRangeRef.current !== null) {
mentionRangeRef.current = null
setMentionQuery(null)
plusMenuRef.current?.close()
}
return
}
setValue(newValue)
}
const wasActive = mentionRangeRef.current !== null
mentionRangeRef.current = { start: active.start, end: active.end }
setMentionQuery(active.query)
if (!wasActive) {
// Anchor above the whole input box (not at the caret) so the menu can never
// overlap the user's typing.
const rect = containerRef.current?.getBoundingClientRect()
const anchor = rect ? { left: rect.left, top: rect.top } : { left: 0, top: 0 }
plusMenuRef.current?.open(anchor, { mention: true })
}
},
[]
)
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const caret = e.target.selectionStart ?? newValue.length
setValue(newValue)
syncMentionState(e.target, newValue, caret)
},
[syncMentionState]
)
const handleSelectAdjust = useCallback(() => {
const textarea = textareaRef.current
@@ -685,8 +712,10 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
setTimeout(() => {
textarea.setSelectionRange(snapPos, snapPos)
}, 0)
return
}
}, [textareaRef, mentionTokensWithContext])
syncMentionState(textarea, textarea.value, pos)
}, [textareaRef, mentionTokensWithContext, syncMentionState])
const handleInput = useCallback(
(e: React.FormEvent<HTMLTextAreaElement>) => {
@@ -805,6 +834,7 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
return (
<div
ref={containerRef}
onClick={handleContainerClick}
className={cn(
'relative z-10 mx-auto w-full max-w-[42rem] cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]',
@@ -855,11 +885,25 @@ export const UserInput = forwardRef<UserInputHandle, UserInputProps>(function Us
ref={plusMenuRef}
availableResources={availableResources}
onResourceSelect={handleResourceSelect}
onFileSelect={handleFileSelectStable}
onClose={handlePlusMenuClose}
textareaRef={textareaRef}
pendingCursorRef={pendingCursorRef}
mentionQuery={mentionQuery ?? undefined}
/>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={handleFileSelectStable}
aria-label='Attach file'
className='h-[28px] w-[28px] rounded-full p-0 hover-hover:bg-[var(--surface-hover)]'
>
<Paperclip className='h-[14px] w-[14px] text-[var(--text-icon)]' strokeWidth={2} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Attach file</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='flex items-center gap-1.5'>
{isSttSupported && <MicButton isListening={isListening} onToggle={toggleListening} />}
@@ -347,7 +347,7 @@ export const ShortInput = memo(function ShortInput({
<>
<Input
ref={ref as React.RefObject<HTMLInputElement>}
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
className='allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] selection:text-transparent placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden'
readOnly={readOnly}
placeholder={placeholder ?? ''}
type='text'
@@ -87,9 +87,20 @@ const DropdownMenuSubContent = React.forwardRef<
))
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
type DropdownMenuContentProps = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
> & {
/**
* Forwarded to Radix's internal FocusScope. The Radix primitive supports this prop at
* runtime but does not expose it in its public TypeScript types — surface it here so
* callers can opt out of the default focus-into-menu behavior on open.
*/
onOpenAutoFocus?: (event: Event) => void
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
DropdownMenuContentProps
>(({ className, sideOffset = 6, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content