fix(ui): Focus first text input by default (#4134)

* Auto-focus input boxes for modals and copilot

* Fix focus in emcn modal

* Fix integrations manager focus

* Change modal tabs to auto focus on first text input

* Auto-focus mothership task chats

---------

Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
Theodore Li
2026-04-13 19:40:19 -04:00
committed by GitHub
co-authored by Theodore Li
parent 7491d70a67
commit c0db9de07b
15 changed files with 172 additions and 67 deletions
@@ -361,6 +361,7 @@ export function UserInput({
} catch {
// Invalid JSON — ignore
}
textareaRef.current?.focus()
return
}
const resourceJson = e.dataTransfer.getData(SIM_RESOURCE_DRAG_TYPE)
@@ -374,11 +375,13 @@ export function UserInput({
} catch {
// Invalid JSON — ignore
}
textareaRef.current?.focus()
return
}
filesRef.current.handleDrop(e)
requestAnimationFrame(() => textareaRef.current?.focus())
},
[handleResourceSelect]
[handleResourceSelect, textareaRef]
)
const handleDragEnter = useCallback((e: React.DragEvent) => {
@@ -407,10 +410,11 @@ export function UserInput({
}, [isSending, textareaRef])
useEffect(() => {
if (isInitialView) {
const raf = window.requestAnimationFrame(() => {
textareaRef.current?.focus()
}
}, [isInitialView, textareaRef])
})
return () => window.cancelAnimationFrame(raf)
}, [textareaRef])
const handleContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -94,7 +94,6 @@ export function RenameDocumentModal({
placeholder='Enter document name'
className={cn(error && 'border-[var(--text-error)]')}
disabled={isSubmitting}
autoFocus
maxLength={255}
autoComplete='off'
autoCorrect='off'
@@ -324,7 +324,6 @@ export function ScheduleModal({ open, onOpenChange, workspaceId, schedule }: Sch
}}
placeholder='e.g., Daily report generation'
className='h-9'
autoFocus
autoComplete='off'
/>
</div>
@@ -168,7 +168,6 @@ export function CreateApiKeyModal({
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
@@ -354,7 +354,6 @@ export function BYOK() {
}}
placeholder={PROVIDERS.find((p) => p.id === editingProvider)?.placeholder}
className='h-9 pr-9'
autoFocus
name='byok_api_key'
autoComplete='off'
autoCorrect='off'
@@ -279,7 +279,6 @@ export function Copilot() {
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
/>
{createError && (
<p className='text-[var(--text-error)] text-small leading-tight'>{createError}</p>
@@ -95,7 +95,6 @@ export function InboxEnableToggle() {
onChange={(e) => setEnableUsername(e.target.value)}
placeholder='e.g., acme'
className='h-9'
autoFocus
/>
<p className='text-[var(--text-muted)] text-caption'>
Leave blank for an auto-generated address.
@@ -250,7 +250,6 @@ export function InboxSettingsTab() {
}}
placeholder='user@example.com'
className='h-9'
autoFocus
/>
</div>
<div className='flex flex-col gap-1'>
@@ -304,7 +303,6 @@ export function InboxSettingsTab() {
}}
placeholder='e.g., new-acme'
className='h-9'
autoFocus
/>
{editAddressError && (
<p className='text-[var(--text-error)] text-small leading-tight'>
@@ -10,6 +10,7 @@ import {
Badge,
Button,
Combobox,
focusFirstTextInputIn,
Input,
Label,
Modal,
@@ -84,6 +85,15 @@ export function IntegrationsManager() {
const [selectedDescriptionDraft, setSelectedDescriptionDraft] = useState('')
const [selectedDisplayNameDraft, setSelectedDisplayNameDraft] = useState('')
const [createStep, setCreateStep] = useState<1 | 2>(1)
const createModalContentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showCreateModal || createStep !== 2) return
const id = window.setTimeout(() => {
focusFirstTextInputIn(createModalContentRef.current)
}, 0)
return () => window.clearTimeout(id)
}, [showCreateModal, createStep])
const [serviceSearch, setServiceSearch] = useState('')
const [copyIdSuccess, setCopyIdSuccess] = useState(false)
const [credentialToDelete, setCredentialToDelete] = useState<WorkspaceCredential | null>(null)
@@ -769,7 +779,7 @@ export function IntegrationsManager() {
if (!open) resetCreateForm()
}}
>
<ModalContent size='md'>
<ModalContent size='md' ref={createModalContentRef}>
{createStep === 1 ? (
<>
<ModalHeader>Connect Integration</ModalHeader>
@@ -785,7 +795,6 @@ export function IntegrationsManager() {
value={serviceSearch}
onChange={(e) => setServiceSearch(e.target.value)}
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
autoFocus
/>
</div>
<div className='flex max-h-[320px] flex-col overflow-y-auto'>
@@ -916,7 +925,6 @@ export function IntegrationsManager() {
autoComplete='off'
data-lpignore='true'
className='mt-1.5'
autoFocus
/>
</div>
<div>
@@ -1053,7 +1061,6 @@ export function IntegrationsManager() {
'min-h-[120px] resize-none border-0 font-mono text-[12px]',
saDragActive && 'opacity-30'
)}
autoFocus
/>
</div>
<div className='mt-1.5'>
@@ -439,6 +439,17 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
return () => window.removeEventListener('mothership-send-message', handler)
}, [setActiveTab, copilotSendMessage])
useEffect(() => {
if (activeTab !== 'copilot') return
const id = window.setTimeout(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
"[data-tab-content='copilot'] textarea"
)
textarea?.focus()
}, 0)
return () => window.clearTimeout(id)
}, [activeTab])
/**
* Handles tab click events
*/
@@ -529,7 +529,6 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
: 'Enter emails'
}
placeholderWithTags='Add email'
autoFocus={userPerms.canAdmin}
disabled={isSubmitting || !userPerms.canAdmin}
fileInputOptions={fileInputOptions}
/>
@@ -61,6 +61,7 @@ export { FormField, type FormFieldProps } from './form-field/form-field'
export { Input, type InputProps, inputVariants } from './input/input'
export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp'
export { Label } from './label/label'
export { focusFirstTextInput, focusFirstTextInputIn } from './modal/auto-focus'
export {
MODAL_SIZES,
Modal,
@@ -0,0 +1,58 @@
/**
* Default `onOpenAutoFocus` handler for emcn modals.
*
* Radix's native behavior focuses the first focusable descendant — usually the close
* button in `ModalHeader`. We instead focus the first visible text-entry control
* (input/textarea/contenteditable) inside the dialog, with the caret at the end.
*
* If no such control exists, we let Radix's default behavior run by not calling
* `preventDefault()`.
*/
const TEXT_INPUT_SELECTOR = [
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"])' +
':not([type="button"]):not([type="submit"]):not([type="reset"])' +
':not([disabled]):not([readonly]):not([tabindex="-1"])',
'textarea:not([disabled]):not([readonly]):not([tabindex="-1"])',
'[contenteditable="true"]:not([tabindex="-1"])',
'[contenteditable=""]:not([tabindex="-1"])',
].join(',')
function isVisible(el: HTMLElement): boolean {
return el.offsetParent !== null || el.getClientRects().length > 0
}
/**
* Focus the first visible text-entry input within `root`, placing caret at end.
* Returns true if an element was focused.
*/
export function focusFirstTextInputIn(root: HTMLElement | null): boolean {
if (!root) return false
const target = Array.from(root.querySelectorAll<HTMLElement>(TEXT_INPUT_SELECTOR)).find(isVisible)
if (!target) return false
target.focus({ preventScroll: false })
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const end = target.value.length
try {
target.setSelectionRange(end, end)
} catch {
// Some input types (number, email, etc.) reject setSelectionRange — ignore.
}
} else if (target.isContentEditable) {
const range = document.createRange()
range.selectNodeContents(target)
range.collapse(false)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
return true
}
export function focusFirstTextInput(event: Event): void {
if (focusFirstTextInputIn(event.currentTarget as HTMLElement | null)) {
event.preventDefault()
}
}
@@ -43,6 +43,7 @@ import { X } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
import { focusFirstTextInput, focusFirstTextInputIn } from './auto-focus'
/**
* Shared animation classes for modal transitions.
@@ -137,58 +138,64 @@ export interface ModalContentProps
const ModalContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ModalContentProps
>(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
const pathname = usePathname()
const isWorkflowPage = pathname?.includes('/w/') ?? false
>(
(
{ className, children, showClose = true, size = 'md', style, onOpenAutoFocus, ...props },
ref
) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
const pathname = usePathname()
const isWorkflowPage = pathname?.includes('/w/') ?? false
React.useEffect(() => {
const timer = setTimeout(() => setIsInteractionReady(true), 100)
return () => clearTimeout(timer)
}, [])
React.useEffect(() => {
const timer = setTimeout(() => setIsInteractionReady(true), 100)
return () => clearTimeout(timer)
}, [])
return (
<ModalPortal>
<ModalOverlay />
<div
className='pointer-events-none fixed inset-0 z-[var(--z-modal)] flex items-center justify-center'
style={{
paddingLeft: isWorkflowPage
? 'calc(var(--sidebar-width) - var(--panel-width))'
: 'var(--sidebar-width)',
}}
>
<DialogPrimitive.Content
ref={ref}
className={cn(
'pointer-events-auto flex max-h-[84vh] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10',
ANIMATION_CLASSES,
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 duration-200',
MODAL_SIZES[size],
className
)}
style={style}
onEscapeKeyDown={(e) => {
if (!isInteractionReady) {
e.preventDefault()
return
}
e.stopPropagation()
return (
<ModalPortal>
<ModalOverlay />
<div
className='pointer-events-none fixed inset-0 z-[var(--z-modal)] flex items-center justify-center'
style={{
paddingLeft: isWorkflowPage
? 'calc(var(--sidebar-width) - var(--panel-width))'
: 'var(--sidebar-width)',
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onPointerUp={(e) => {
e.stopPropagation()
}}
{...props}
>
{children}
</DialogPrimitive.Content>
</div>
</ModalPortal>
)
})
<DialogPrimitive.Content
ref={ref}
className={cn(
'pointer-events-auto flex max-h-[84vh] flex-col overflow-hidden rounded-xl bg-[var(--bg)] text-small ring-1 ring-foreground/10',
ANIMATION_CLASSES,
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 duration-200',
MODAL_SIZES[size],
className
)}
style={style}
onEscapeKeyDown={(e) => {
if (!isInteractionReady) {
e.preventDefault()
return
}
e.stopPropagation()
}}
onPointerDown={(e) => {
e.stopPropagation()
}}
onPointerUp={(e) => {
e.stopPropagation()
}}
onOpenAutoFocus={onOpenAutoFocus ?? focusFirstTextInput}
{...props}
>
{children}
</DialogPrimitive.Content>
</div>
</ModalPortal>
)
}
)
ModalContent.displayName = 'ModalContent'
@@ -247,7 +254,27 @@ ModalDescription.displayName = 'ModalDescription'
/**
* Modal tabs root component. Wraps tab list and content panels.
*/
const ModalTabs = TabsPrimitive.Root
const ModalTabs = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root>
>(({ onValueChange, ...props }, ref) => {
const rootRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, [])
const handleValueChange = (value: string) => {
onValueChange?.(value)
window.requestAnimationFrame(() => {
const root = rootRef.current
if (!root) return
const panel = root.querySelector<HTMLElement>('[role="tabpanel"][data-state="active"]')
focusFirstTextInputIn(panel)
})
}
return <TabsPrimitive.Root ref={rootRef} onValueChange={handleValueChange} {...props} />
})
ModalTabs.displayName = 'ModalTabs'
interface ModalTabsListProps extends React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> {
/** Currently active tab value for indicator positioning */
@@ -346,6 +373,10 @@ ModalTabsTrigger.displayName = 'ModalTabsTrigger'
/**
* Modal tab content component. Content panel for each tab.
* Includes bottom padding for consistent spacing across all tabbed modals.
*
* When this panel mounts (i.e. its tab becomes active), focus moves to the first
* visible text-entry input inside it so typing works immediately. Tabs with no
* text input are untouched.
*/
const ModalTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@@ -30,6 +30,7 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'
import { X } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
import { Button } from '../button/button'
import { focusFirstTextInput } from '../modal/auto-focus'
import { Modal, type ModalContentProps, ModalOverlay, ModalPortal } from '../modal/modal'
const ANIMATION_CLASSES =
@@ -59,7 +60,7 @@ const SModalClose = DialogPrimitive.Close
const SModalContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ModalContentProps
>(({ className, children, style, ...props }, ref) => {
>(({ className, children, style, onOpenAutoFocus, ...props }, ref) => {
const [isInteractionReady, setIsInteractionReady] = React.useState(false)
React.useEffect(() => {
@@ -95,6 +96,7 @@ const SModalContent = React.forwardRef<
onPointerUp={(e) => {
e.stopPropagation()
}}
onOpenAutoFocus={onOpenAutoFocus ?? focusFirstTextInput}
{...props}
>
{children}