Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Auto-focus input boxes for modals and copilot
  • Loading branch information
TheodoreSpeaks committed Apr 13, 2026
commit 1524ec4a489640da99f7a59676aaad6e1d5fc77a
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export function UserInput({
} catch {
// Invalid JSON — ignore
}
textareaRef.current?.focus()
return
}
const resourceJson = e.dataTransfer.getData(SIM_RESOURCE_DRAG_TYPE)
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ export function CreateApiKeyModal({
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Clipboard } from 'lucide-react'
import { useParams } from 'next/navigation'
Expand Down Expand Up @@ -195,8 +195,20 @@ export function A2aDeploy({
}
}, [startBlockId, startBlockInputFormat, missingFields, collaborativeSetSubblockValue])

const nameInputRef = useRef<HTMLInputElement>(null)
const [name, setName] = useState('')
const [description, setDescription] = useState('')

useEffect(() => {
const id = window.setTimeout(() => {
const el = nameInputRef.current
if (!el) return
el.focus()
const end = el.value.length
el.setSelectionRange(end, end)
}, 0)
return () => window.clearTimeout(id)
}, [])
const [authScheme, setAuthScheme] = useState<AuthScheme>('apiKey')
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false)
const [skillTags, setSkillTags] = useState<string[]>([])
Expand Down Expand Up @@ -720,6 +732,7 @@ console.log(data);`
</Label>
<Input
id='a2a-name'
ref={nameInputRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder='Enter agent name'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ export function McpDeploy({
Description
</Label>
<Textarea
autoFocus
placeholder='Describe what this tool does...'
className='min-h-[100px] resize-none'
value={toolDescription}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
52 changes: 52 additions & 0 deletions apps/sim/components/emcn/components/modal/auto-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* 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
}

export function focusFirstTextInput(event: Event): void {
const content = event.currentTarget as HTMLElement | null
if (!content) return

const target = Array.from(
content.querySelectorAll<HTMLElement>(TEXT_INPUT_SELECTOR)
).find(isVisible)
if (!target) return

event.preventDefault()
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)
}
}
4 changes: 3 additions & 1 deletion apps/sim/components/emcn/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from './auto-focus'

/**
* Shared animation classes for modal transitions.
Expand Down Expand Up @@ -137,7 +138,7 @@ export interface ModalContentProps
const ModalContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ModalContentProps
>(({ className, children, showClose = true, size = 'md', style, ...props }, ref) => {
>(({ 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
Expand Down Expand Up @@ -181,6 +182,7 @@ const ModalContent = React.forwardRef<
onPointerUp={(e) => {
e.stopPropagation()
}}
onOpenAutoFocus={onOpenAutoFocus ?? focusFirstTextInput}
{...props}
>
{children}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/components/emcn/components/s-modal/s-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -95,6 +96,7 @@ const SModalContent = React.forwardRef<
onPointerUp={(e) => {
e.stopPropagation()
}}
onOpenAutoFocus={onOpenAutoFocus ?? focusFirstTextInput}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
{...props}
>
{children}
Expand Down