feat: implement comprehensive focus management for comment thread

- Add forwardRef support to MentionInput to expose textarea ref
- Auto-focus reply input when thread opens (100ms delay)
- Restore focus after reply submission and edit operations
- Add Esc key handler to close thread with smart guards
- Enhance accessibility with ARIA attributes (dialog, modal, labelledby)
- Improve keyboard navigation and user experience

Implements P0-P3 priorities following WCAG 2.1 AA accessibility standards
This commit is contained in:
lyzno1 2025-10-12 13:21:57 +08:00
parent 9aaace706b
commit 0ac32188c5
No known key found for this signature in database
2 changed files with 74 additions and 6 deletions

View File

@ -1,10 +1,12 @@
'use client'
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
@ -34,7 +36,7 @@ type MentionInputProps = {
autoFocus?: boolean
}
export const MentionInput: FC<MentionInputProps> = memo(({
const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(({
value,
onChange,
onSubmit,
@ -45,7 +47,7 @@ export const MentionInput: FC<MentionInputProps> = memo(({
className,
isEditing = false,
autoFocus = false,
}) => {
}, forwardedRef) => {
const params = useParams()
const { t } = useTranslation()
const appId = params.appId as string
@ -55,6 +57,9 @@ export const MentionInput: FC<MentionInputProps> = memo(({
const actionRightRef = useRef<HTMLDivElement | null>(null)
const baseTextareaHeightRef = useRef<number | null>(null)
// Expose textarea ref to parent component
useImperativeHandle(forwardedRef, () => textareaRef.current!, [])
const workflowStore = useWorkflowStore()
const mentionUsersFromStore = useStore(state => (
appId ? state.mentionableUsersCache[appId] : undefined
@ -630,4 +635,6 @@ export const MentionInput: FC<MentionInputProps> = memo(({
)
})
MentionInput.displayName = 'MentionInput'
MentionInputInner.displayName = 'MentionInputInner'
export const MentionInput = memo(MentionInputInner)

View File

@ -163,10 +163,44 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ id: '', content: '' })
const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null)
// Focus management refs
const replyInputRef = useRef<HTMLTextAreaElement>(null)
const threadRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setReplyContent('')
}, [comment.id])
// P0: Auto-focus reply input when thread opens or comment changes
useEffect(() => {
const timer = setTimeout(() => {
if (replyInputRef.current && !editingReply.id && onReply)
replyInputRef.current.focus()
}, 100)
return () => clearTimeout(timer)
}, [comment.id, editingReply.id, onReply])
// P2: Handle Esc key to close thread
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept if actively editing a reply
if (editingReply.id) return
// Don't intercept if mention dropdown is open (let MentionInput handle it)
if (document.querySelector('[data-mention-dropdown]')) return
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onClose()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [onClose, editingReply.id])
const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
if (!onReply || replySubmitting) return
@ -174,6 +208,11 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
try {
await onReply(content, mentionedUserIds)
// P0: Restore focus to reply input after successful submission
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}
catch (error) {
console.error('Failed to send reply', error)
@ -195,6 +234,11 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
const handleCancelEdit = useCallback(() => {
setEditingReply({ id: '', content: '' })
// P1: Restore focus to reply input after canceling edit
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}, [])
const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => {
@ -203,6 +247,11 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
if (!trimmed) return
await onReplyEdit(editingReply.id, trimmed, mentionedUserIds)
setEditingReply({ id: '', content: '' })
// P1: Restore focus to reply input after saving edit
setTimeout(() => {
replyInputRef.current?.focus()
}, 0)
}, [editingReply, onReplyEdit])
const replies = comment.replies || []
@ -272,9 +321,20 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
transform: 'translateY(-20%)',
}}
>
<div className='relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
<div
ref={threadRef}
className='relative flex h-[360px] flex-col overflow-hidden rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'
role='dialog'
aria-modal='true'
aria-labelledby='comment-thread-title'
>
<div className='flex items-center justify-between rounded-t-2xl border-b border-components-panel-border bg-components-panel-bg-blur px-4 py-3'>
<div className='font-semibold uppercase text-text-primary'>{t('workflow.comments.panelTitle')}</div>
<div
id='comment-thread-title'
className='font-semibold uppercase text-text-primary'
>
{t('workflow.comments.panelTitle')}
</div>
<div className='flex items-center gap-1'>
<Tooltip
popupContent={t('workflow.comments.aria.deleteComment')}
@ -502,6 +562,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
/>
<div className='flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur p-[2px] shadow-sm'>
<MentionInput
ref={replyInputRef}
value={replyContent}
onChange={setReplyContent}
onSubmit={handleReplySubmit}