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) * - Error list (if any)
*/ */
import React from 'react' import React, { useRef } from 'react'
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react' import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
import FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../hooks/useDialogFocus'
interface CleanerProgress { interface CleanerProgress {
message: string message: string
@@ -46,6 +48,34 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
progress = null, progress = null,
startTime = 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 if (!isOpen) return null
const hasErrors = errors.length > 0 const hasErrors = errors.length > 0
@@ -91,188 +121,202 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
}, [showProgress, startTime, progress, now]) }, [showProgress, startTime, progress, now])
return ( return (
<div className="execution-report-overlay" onClick={showProgress ? undefined : onClose}> <FocusLock {...focusLockProps}>
<div <div
className="execution-report-dialog" className="execution-report-overlay"
onClick={(e) => e.stopPropagation()} onClick={showProgress ? undefined : onClose}
style={{ width: showProgress ? '560px' : '480px' }} role="dialog"
aria-modal="true"
aria-labelledby={
showProgress ? 'execution-dialog-progress-title' : 'execution-dialog-report-title'
}
> >
{showProgress ? ( <div
// Progress View ref={dialogRef}
<div className="progress-header"> className="execution-report-dialog"
<div className="progress-icon-wrapper"> onClick={(e) => e.stopPropagation()}
<Loader2 className="progress-icon spinning" /> style={{ width: showProgress ? '560px' : '480px' }}
</div> >
<h2 className="progress-title">...</h2>
<p className="progress-subtitle">{progress?.message || '处理中...'}</p>
</div>
) : (
// Result View
<div className="report-header">
<div className="report-icon-wrapper">
{dryRun ? (
<Package className="report-icon preview" />
) : hasErrors ? (
<XCircle className="report-icon error" />
) : (
<CheckCircle className="report-icon success" />
)}
</div>
<h2 className="report-title">
{dryRun ? '预览执行报告' : hasErrors ? '执行完成 (有错误)' : '执行完成'}
</h2>
<p className="report-subtitle">
{dryRun
? '预览模式 - 未实际删除数据'
: hasErrors
? '部分操作未能完成,请查看下方错误信息'
: '所有操作已成功完成'}
</p>
</div>
)}
<div className="report-body">
{showProgress ? ( {showProgress ? (
// Progress View Content // Progress View
<div className="progress-content"> <div className="progress-header">
<div className="progress-bar-container"> <div className="progress-icon-wrapper">
<div <Loader2 className="progress-icon spinning" />
className="progress-bar-fill"
style={{ width: `${progress?.progress || 0}%` }}
/>
</div> </div>
<h2 id="execution-dialog-progress-title" className="progress-title">
<div className="progress-stats"> ...
<div className="progress-stat-item"> </h2>
<span className="stat-label"></span> <p className="progress-subtitle" aria-live="polite" aria-atomic="true">
<span className="stat-value"> {progress?.message || '处理中...'}
{Math.min(progress!.currentOrderIndex, progress!.totalOrders)} /{' '} </p>
{progress!.totalOrders}
</span>
</div>
{progress!.totalMaterialsInOrder > 0 && (
<div className="progress-stat-item">
<span className="stat-label"></span>
<span className="stat-value">
{progress!.currentMaterialIndex} / {progress!.totalMaterialsInOrder}
</span>
</div>
)}
<div className="progress-stat-item">
<span className="stat-label"></span>
<span className="stat-value">{Math.round(progress?.progress || 0)}%</span>
</div>
</div>
{progress?.currentOrderNumber && (
<div className="current-order-info">
<Package size={14} className="order-icon" />
<span className="order-label">:</span>
<span className="order-number">{progress.currentOrderNumber}</span>
</div>
)}
{estimatedTime && (
<div className="estimated-time-info">
<div className="estimated-time-item">
<span className="time-label"></span>
<span className="time-value">{estimatedTime.remainingMinutes} </span>
</div>
<div className="estimated-time-item">
<span className="time-label"></span>
<span className="time-value">{estimatedTime.formattedTime}</span>
<span className="time-label"></span>
</div>
</div>
)}
</div> </div>
) : ( ) : (
// Result View Content // Result View
<> <div className="report-header">
<div className="stats-grid"> <div className="report-icon-wrapper">
<div className="stat-card"> {dryRun ? (
<div className="stat-icon orders"> <Package className="report-icon preview" />
<Package size={20} /> ) : hasErrors ? (
<XCircle className="report-icon error" />
) : (
<CheckCircle className="report-icon success" />
)}
</div>
<h2 id="execution-dialog-report-title" className="report-title">
{dryRun ? '预览执行报告' : hasErrors ? '执行完成 (有错误)' : '执行完成'}
</h2>
<p className="report-subtitle">
{dryRun
? '预览模式 - 未实际删除数据'
: hasErrors
? '部分操作未能完成,请查看下方错误信息'
: '所有操作已成功完成'}
</p>
</div>
)}
<div className="report-body">
{showProgress ? (
// Progress View Content
<div className="progress-content">
<div className="progress-bar-container">
<div
className="progress-bar-fill"
style={{ width: `${progress?.progress || 0}%` }}
/>
</div>
<div className="progress-stats">
<div className="progress-stat-item">
<span className="stat-label"></span>
<span className="stat-value">
{Math.min(progress!.currentOrderIndex, progress!.totalOrders)} /{' '}
{progress!.totalOrders}
</span>
</div> </div>
<div className="stat-content"> {progress!.totalMaterialsInOrder > 0 && (
<div className="stat-label"></div> <div className="progress-stat-item">
<div className="stat-value">{ordersProcessed}</div> <span className="stat-label"></span>
<span className="stat-value">
{progress!.currentMaterialIndex} / {progress!.totalMaterialsInOrder}
</span>
</div>
)}
<div className="progress-stat-item">
<span className="stat-label"></span>
<span className="stat-value">{Math.round(progress?.progress || 0)}%</span>
</div> </div>
</div> </div>
<div className="stat-card"> {progress?.currentOrderNumber && (
<div className="stat-icon deleted"> <div className="current-order-info">
<CheckCircle size={20} /> <Package size={14} className="order-icon" />
<span className="order-label">:</span>
<span className="order-number">{progress.currentOrderNumber}</span>
</div> </div>
<div className="stat-content"> )}
<div className="stat-label">{dryRun ? '拟删除物料' : '删除物料'}</div>
<div className="stat-value">{materialsDeleted}</div>
</div>
</div>
<div className="stat-card"> {estimatedTime && (
<div className="stat-icon skipped"> <div className="estimated-time-info">
<SkipForward size={20} /> <div className="estimated-time-item">
<span className="time-label"></span>
<span className="time-value">{estimatedTime.remainingMinutes} </span>
</div>
<div className="estimated-time-item">
<span className="time-label"></span>
<span className="time-value">{estimatedTime.formattedTime}</span>
<span className="time-label"></span>
</div>
</div> </div>
<div className="stat-content"> )}
<div className="stat-label"></div> </div>
<div className="stat-value">{materialsSkipped}</div> ) : (
// Result View Content
<>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon orders">
<Package size={20} />
</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{ordersProcessed}</div>
</div>
</div> </div>
<div className="stat-card">
<div className="stat-icon deleted">
<CheckCircle size={20} />
</div>
<div className="stat-content">
<div className="stat-label">{dryRun ? '拟删除物料' : '删除物料'}</div>
<div className="stat-value">{materialsDeleted}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon skipped">
<SkipForward size={20} />
</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value">{materialsSkipped}</div>
</div>
</div>
{hasErrors && (
<div className="stat-card">
<div className="stat-icon errors">
<XCircle size={20} />
</div>
<div className="stat-content">
<div className="stat-label"></div>
<div className="stat-value error">{errors.length}</div>
</div>
</div>
)}
</div> </div>
{hasErrors && ( {hasErrors && (
<div className="stat-card"> <div className="errors-section">
<div className="stat-icon errors"> <div className="errors-title"></div>
<XCircle size={20} /> <div className="errors-list">
</div> {errors.map((error, index) => (
<div className="stat-content"> <div key={index} className="error-item">
<div className="stat-label"></div> <XCircle size={14} className="error-icon" />
<div className="stat-value error">{errors.length}</div> <span className="error-text">{error}</span>
</div>
))}
</div> </div>
</div> </div>
)} )}
</div>
{hasErrors && ( {!hasErrors && !dryRun && (
<div className="errors-section"> <div className="success-message">
<div className="errors-title"></div> <CheckCircle size={16} className="success-icon" />
<div className="errors-list"> <span> ERP </span>
{errors.map((error, index) => (
<div key={index} className="error-item">
<XCircle size={14} className="error-icon" />
<span className="error-text">{error}</span>
</div>
))}
</div> </div>
</div> )}
)}
{!hasErrors && !dryRun && ( {!hasErrors && dryRun && (
<div className="success-message"> <div className="preview-message">
<CheckCircle size={16} className="success-icon" /> <Package size={16} className="preview-icon" />
<span> ERP </span> <span></span>
</div> </div>
)} )}
</>
)}
</div>
{!hasErrors && dryRun && ( <div className="report-footer">
<div className="preview-message"> {!showProgress && (
<Package size={16} className="preview-icon" /> <button className="btn-report-close" onClick={onClose}>
<span></span>
</div> </button>
)} )}
</> </div>
)}
</div>
<div className="report-footer"> <style>{`
{!showProgress && (
<button className="btn-report-close" onClick={onClose}>
</button>
)}
</div>
<style>{`
.execution-report-overlay { .execution-report-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -698,8 +742,9 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
background: #096dd9; background: #096dd9;
} }
`}</style> `}</style>
</div>
</div> </div>
</div> </FocusLock>
) )
} }

View File

@@ -7,7 +7,9 @@
* - Return selected user info * - 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 { export interface UserInfo {
id: number id: number
@@ -32,6 +34,14 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
onCancel onCancel
}) => { }) => {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null) 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 // Reset selection when dialog opens
useEffect(() => { useEffect(() => {
@@ -58,60 +68,68 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="user-selection-overlay"> <FocusLock {...focusLockProps}>
<div className="user-selection-dialog"> <div
<div className="user-selection-header"> className="user-selection-overlay"
<h2 className="user-selection-title"></h2> role="dialog"
<p className="user-selection-hint">{currentUsername}</p> aria-modal="true"
</div> aria-labelledby="user-selection-dialog-title"
>
<div className="user-selection-dialog" ref={dialogRef}>
<div className="user-selection-header">
<h2 className="user-selection-title" id="user-selection-dialog-title">
</h2>
<p className="user-selection-hint">{currentUsername}</p>
</div>
<div className="user-selection-body"> <div className="user-selection-body">
<div className="user-list"> <div className="user-list">
{users.map((user) => ( {users.map((user) => (
<div <div
key={user.id} key={user.id}
className={`user-item ${selectedUserId === user.id ? 'selected' : ''}`} className={`user-item ${selectedUserId === user.id ? 'selected' : ''}`}
onClick={() => setSelectedUserId(user.id)} onClick={() => setSelectedUserId(user.id)}
onDoubleClick={() => handleDoubleClick(user)} onDoubleClick={() => handleDoubleClick(user)}
> >
<div className="user-item-content"> <div className="user-item-content">
<div className="user-item-row">
<span className="user-name">{user.username}</span>
<span className={`user-type user-type-${user.userType.toLowerCase()}`}>
{user.userType}
</span>
</div>
{user.createTime && (
<div className="user-item-row"> <div className="user-item-row">
<span className="user-create-time"> <span className="user-name">{user.username}</span>
{new Date(user.createTime).toLocaleString('zh-CN')} <span className={`user-type user-type-${user.userType.toLowerCase()}`}>
{user.userType}
</span> </span>
</div> </div>
)} {user.createTime && (
<div className="user-item-row">
<span className="user-create-time">
{new Date(user.createTime).toLocaleString('zh-CN')}
</span>
</div>
)}
</div>
</div> </div>
</div> ))}
))} </div>
</div> </div>
</div>
<div className="user-selection-footer"> <div className="user-selection-footer">
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={handleConfirm} onClick={handleConfirm}
disabled={selectedUserId === null} disabled={selectedUserId === null}
> >
</button> </button>
<button className="btn btn-secondary" onClick={onCancel}> <button className="btn btn-secondary" onClick={onCancel}>
</button> </button>
</div> </div>
<div className="user-selection-hint-footer"></div> <div className="user-selection-hint-footer"></div>
</div>
</div> </div>
<style>{` <style>{`
.user-selection-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@@ -278,7 +296,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
`}</style> `}</style>
</div> </FocusLock>
) )
} }