mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Theodore Li
parent
7491d70a67
commit
c0db9de07b
@@ -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>) => {
|
||||
|
||||
-1
@@ -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'
|
||||
|
||||
-1
@@ -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>
|
||||
|
||||
-1
@@ -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'>
|
||||
|
||||
+11
-4
@@ -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
|
||||
*/
|
||||
|
||||
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user