mirror of https://github.com/langgenius/dify.git
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:
parent
9aaace706b
commit
0ac32188c5
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue