feat(a11y): add focus management to UserSelectionDialog and ExecutionReportDialog

UserSelectionDialog:
- Add ARIA attributes (role, aria-modal, aria-labelledby)
- Integrate useDialogFocus hook with FocusLock
- Add Escape key handling (was missing)
- Initial focus on first user card

ExecutionReportDialog:
- Add ARIA attributes with dynamic aria-labelledby
- Add aria-live for progress updates
- Integrate useDialogFocus hook with FocusLock
- Escape key only closes when not executing
This commit is contained in:
test
2026-03-08 17:16:53 +08:00
parent d1f2f40123
commit 72d8d981b1
2 changed files with 267 additions and 204 deletions

View File

@@ -7,8 +7,10 @@
* - Error list (if any)
*/
import React from 'react'
import React, { useRef } from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
import FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../hooks/useDialogFocus'
interface CleanerProgress {
message: string
@@ -46,6 +48,34 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
progress = null,
startTime = null
}) => {
const dialogRef = useRef<HTMLDivElement>(null)
// Setup focus management with custom escape key handling
const { focusLockProps } = useDialogFocus({
isOpen,
dialogRef,
onClose
})
// Custom escape key handling - only close when NOT executing
React.useEffect(() => {
if (!isOpen) return
const handleKeyDown = (event: KeyboardEvent) => {
// Only allow escape to close when not executing
if ((event.key === 'Escape' || event.keyCode === 27) && !isExecuting) {
event.preventDefault()
event.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, isExecuting, onClose])
if (!isOpen) return null
const hasErrors = errors.length > 0
@@ -91,8 +121,18 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
}, [showProgress, startTime, progress, now])
return (
<div className="execution-report-overlay" onClick={showProgress ? undefined : onClose}>
<FocusLock {...focusLockProps}>
<div
className="execution-report-overlay"
onClick={showProgress ? undefined : onClose}
role="dialog"
aria-modal="true"
aria-labelledby={
showProgress ? 'execution-dialog-progress-title' : 'execution-dialog-report-title'
}
>
<div
ref={dialogRef}
className="execution-report-dialog"
onClick={(e) => e.stopPropagation()}
style={{ width: showProgress ? '560px' : '480px' }}
@@ -103,8 +143,12 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
<div className="progress-icon-wrapper">
<Loader2 className="progress-icon spinning" />
</div>
<h2 className="progress-title">...</h2>
<p className="progress-subtitle">{progress?.message || '处理中...'}</p>
<h2 id="execution-dialog-progress-title" className="progress-title">
...
</h2>
<p className="progress-subtitle" aria-live="polite" aria-atomic="true">
{progress?.message || '处理中...'}
</p>
</div>
) : (
// Result View
@@ -118,7 +162,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
<CheckCircle className="report-icon success" />
)}
</div>
<h2 className="report-title">
<h2 id="execution-dialog-report-title" className="report-title">
{dryRun ? '预览执行报告' : hasErrors ? '执行完成 (有错误)' : '执行完成'}
</h2>
<p className="report-subtitle">
@@ -700,6 +744,7 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
`}</style>
</div>
</div>
</FocusLock>
)
}

View File

@@ -7,7 +7,9 @@
* - Return selected user info
*/
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../hooks/useDialogFocus'
export interface UserInfo {
id: number
@@ -32,6 +34,14 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
onCancel
}) => {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const { focusLockProps } = useDialogFocus({
isOpen,
dialogRef,
onClose: onCancel,
initialFocusSelector: users.length > 0 ? '.user-item:first-child' : undefined
})
// Reset selection when dialog opens
useEffect(() => {
@@ -58,10 +68,18 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
if (!isOpen) return null
return (
<div className="user-selection-overlay">
<div className="user-selection-dialog">
<FocusLock {...focusLockProps}>
<div
className="user-selection-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="user-selection-dialog-title"
>
<div className="user-selection-dialog" ref={dialogRef}>
<div className="user-selection-header">
<h2 className="user-selection-title"></h2>
<h2 className="user-selection-title" id="user-selection-dialog-title">
</h2>
<p className="user-selection-hint">{currentUsername}</p>
</div>
@@ -109,9 +127,9 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
<div className="user-selection-hint-footer"></div>
</div>
</div>
<style>{`
.user-selection-overlay {
position: fixed;
top: 0;
left: 0;
@@ -278,7 +296,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
border-top: 1px solid #f0f0f0;
}
`}</style>
</div>
</FocusLock>
)
}