From bcfe0eecca1fe4a03fa530b8926abbe5dd58351a Mon Sep 17 00:00:00 2001 From: test Date: Sun, 8 Mar 2026 18:29:39 +0800 Subject: [PATCH] fix(a11y): improve focus restoration with timing fixes, validation, and error handling Core improvements: - Fix focus restoration timing by using queueMicrotask only (removed double-layer async) - Add comprehensive error handling with dev-mode logging for all failure scenarios - Validate element visibility (display: none, visibility: hidden) before restoring focus - Check disabled state and implement fallback to nearest focusable ancestor - Add findNearestFocusableElement() helper for robust fallback strategy - Add tabindex="-1" to focusable selectors for better focus management - Use preventScroll option when calling focus() to prevent scroll jumps Additional fixes: - Remove unnecessary type conversion in Modal.tsx - Fix TypeScript unused variable errors in main process - Clean up unused imports in bip-users-dao.ts - Add ARIA attributes and focus management to LoginDialog - Add triggerRef support to UserSelectionDialog and ExecutionReportDialog - Refactor ExecutionReportDialog to use Modal component - Improve MaterialTypeManagementDialog with focus management Co-Authored-By: Claude Sonnet 4.5 --- src/main/ipc/index.ts | 8 +- src/main/services/user/bip-users-dao.ts | 1 - src/renderer/src/App.tsx | 5 + .../src/components/ExecutionReportDialog.tsx | 832 +++++------------- src/renderer/src/components/LoginDialog.tsx | 152 ++-- .../MaterialTypeManagementDialog.tsx | 12 +- .../src/components/UserSelectionDialog.tsx | 289 ++---- src/renderer/src/components/ui/Modal.tsx | 31 +- src/renderer/src/hooks/useDialogFocus.ts | 127 ++- src/renderer/src/pages/CleanerPage.tsx | 7 + 10 files changed, 493 insertions(+), 971 deletions(-) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 28b54dd..8eb95df 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -59,9 +59,11 @@ export function withErrorHandling( const code = getErrorCode(error) // Serialize error with full details - const serializedError = serializeError(error) - const errorToLog = - process.env.NODE_ENV === 'production' ? sanitizeError(serializedError) : serializedError + if (process.env.NODE_ENV === 'production') { + sanitizeError(serializeError(error)) + } else { + serializeError(error) + } if (isBaseError(error)) { logError(log, `[${context}] ${error.name}`, error, { diff --git a/src/main/services/user/bip-users-dao.ts b/src/main/services/user/bip-users-dao.ts index 9f23f52..9f878f9 100644 --- a/src/main/services/user/bip-users-dao.ts +++ b/src/main/services/user/bip-users-dao.ts @@ -14,7 +14,6 @@ import { ConfigManager } from '../config/config-manager' import sql from 'mssql' import type { UserInfo } from '../../types/user.types' import { createLogger, logError } from '../logger' -import { serializeError } from '../logger/error-utils' const log = createLogger('BipUsersDao') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 60d3917..bf79e63 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -45,6 +45,9 @@ function App(): React.JSX.Element { // Track if current session is switched by Admin const [isSwitchedByAdmin, setIsSwitchedByAdmin] = useState(false) + // Ref for logout button (for focus restoration) + const logoutButtonRef = React.useRef(null) + // Navigation state const [currentPage, setCurrentPage] = useState('extractor') // Default to extractor for the new layout @@ -251,6 +254,7 @@ function App(): React.JSX.Element { currentUsername={currentUser?.username || ''} onSelectUser={handleUserSelect} onCancel={handleUserSelectionCancel} + triggerRef={logoutButtonRef} /> {errorMessage &&
{errorMessage}
} @@ -374,6 +378,7 @@ function App(): React.JSX.Element { {shouldShowLogout && ( - )} - - - +
+ 将会在 + {estimatedTime.formattedTime} + 执行完毕 +
+ + )} - - + ) : ( + // Result View Content + <> +
+
+
+ +
+
+
处理订单
+
{ordersProcessed}
+
+
+ +
+
+ +
+
+
{dryRun ? '拟删除物料' : '删除物料'}
+
{materialsDeleted}
+
+
+ +
+
+ +
+
+
跳过物料
+
{materialsSkipped}
+
+
+ + {hasErrors && ( +
+
+ +
+
+
错误数量
+
{errors.length}
+
+
+ )} +
+ + {hasErrors && ( +
+
错误详情
+
+ {errors.map((error, index) => ( +
+ + {error} +
+ ))} +
+
+ )} + + {!hasErrors && !dryRun && ( +
+ + 操作已成功完成,数据已同步到 ERP 系统 +
+ )} + + {!hasErrors && dryRun && ( +
+ + 预览模式结束,数据未实际修改。确认无误后可正式执行。 +
+ )} + + )} + ) } diff --git a/src/renderer/src/components/LoginDialog.tsx b/src/renderer/src/components/LoginDialog.tsx index a290eba..7ce74d6 100644 --- a/src/renderer/src/components/LoginDialog.tsx +++ b/src/renderer/src/components/LoginDialog.tsx @@ -8,8 +8,7 @@ */ import React, { useState, useRef } from 'react' -import FocusLock from 'react-focus-lock' -import { useDialogFocus } from '../hooks/useDialogFocus' +import { Modal } from './ui/Modal' interface LoginDialogProps { isOpen: boolean @@ -31,26 +30,15 @@ export const LoginDialog: React.FC = ({ const [isLoggingIn, setIsLoggingIn] = useState(false) const [errorMessage, setErrorMessage] = useState('') const usernameInputRef = useRef(null) - const dialogRef = useRef(null) const errorRef = useRef(null) - // Setup focus management with useDialogFocus hook - const { focusLockProps } = useDialogFocus({ - isOpen, - dialogRef, - onClose: onCancel, - initialFocusSelector: 'input[type="text"]' // Focus username input initially - }) - // Display error message with aria-live const showError = (message: string): void => { setErrorMessage(message) - // Also call the original onError callback for backward compatibility onError(message) } const handleLogin = async (): Promise => { - // Clear error message when attempting login setErrorMessage('') if (!username.trim()) { @@ -80,89 +68,81 @@ export const LoginDialog: React.FC = ({ } } - if (!isOpen) return null - return ( - -
-
-
-

- 请登录 -

-

当前计算机:{computerName}

+ +
+ {/* Error message area with aria-live for screen readers */} + {errorMessage && ( + + )} - {/* Error message area with aria-live for screen readers */} - {errorMessage && ( -
- {errorMessage} -
- )} +
+
当前计算机:{computerName}
-
-
- - setUsername(e.target.value)} - placeholder="请输入用户名" - disabled={isLoggingIn} - /> -
- -
- - setPassword(e.target.value)} - placeholder="请输入密码" - disabled={isLoggingIn} - /> -
-
- -
- - + />
-
v1.0
+
+ + setPassword(e.target.value)} + placeholder="请输入密码" + disabled={isLoggingIn} + /> +
+ +
+ + +
+ +
v1.0
- +
) } export default LoginDialog - -// Styles are now handled by Tailwind classes in the component diff --git a/src/renderer/src/components/MaterialTypeManagementDialog.tsx b/src/renderer/src/components/MaterialTypeManagementDialog.tsx index e7fccc4..e14a9cd 100644 --- a/src/renderer/src/components/MaterialTypeManagementDialog.tsx +++ b/src/renderer/src/components/MaterialTypeManagementDialog.tsx @@ -27,13 +27,15 @@ interface MaterialTypeManagementDialogProps { onClose: () => void isAdmin: boolean currentUsername: string + triggerRef?: React.RefObject } export const MaterialTypeManagementDialog: React.FC = ({ isOpen, onClose, isAdmin, - currentUsername + currentUsername, + triggerRef }) => { const [rows, setRows] = useState([]) const [managers, setManagers] = useState([]) @@ -304,7 +306,13 @@ export const MaterialTypeManagementDialog: React.FC +
{/* Manager filter (admin only) */} {isAdmin && ( diff --git a/src/renderer/src/components/UserSelectionDialog.tsx b/src/renderer/src/components/UserSelectionDialog.tsx index e62dd38..b4ca52d 100644 --- a/src/renderer/src/components/UserSelectionDialog.tsx +++ b/src/renderer/src/components/UserSelectionDialog.tsx @@ -8,8 +8,7 @@ */ import React, { useState, useEffect, useRef } from 'react' -import FocusLock from 'react-focus-lock' -import { useDialogFocus } from '../hooks/useDialogFocus' +import { Modal } from './ui/Modal' export interface UserInfo { id: number @@ -24,6 +23,7 @@ interface UserSelectionDialogProps { currentUsername: string onSelectUser: (user: UserInfo) => void onCancel: () => void + triggerRef?: React.RefObject } export const UserSelectionDialog: React.FC = ({ @@ -31,18 +31,12 @@ export const UserSelectionDialog: React.FC = ({ users, currentUsername, onSelectUser, - onCancel + onCancel, + triggerRef }) => { const [selectedUserId, setSelectedUserId] = useState(null) const dialogRef = useRef(null) - const { focusLockProps } = useDialogFocus({ - isOpen, - dialogRef, - onClose: onCancel, - initialFocusSelector: users.length > 0 ? '.user-item:first-child' : undefined - }) - // Reset selection when dialog opens useEffect(() => { if (isOpen) { @@ -65,238 +59,85 @@ export const UserSelectionDialog: React.FC = ({ onSelectUser(user) } + const userTypeStyles: Record = { + Admin: 'bg-amber-50 text-amber-600', + User: 'bg-blue-50 text-blue-600', + Guest: 'bg-gray-100 text-gray-600' + } + if (!isOpen) return null return ( - -
-
-
-

- 选择用户 -

-

当前登录:{currentUsername}

-
+ 0 ? '.user-item:first-child' : undefined} + ariaDescribedBy="user-selection-description" + > +
+
当前登录:{currentUsername}
+

+ 从列表中选择一个用户,双击可直接确认选择 +

-
-
- {users.map((user) => ( -
setSelectedUserId(user.id)} - onDoubleClick={() => handleDoubleClick(user)} - > -
-
- {user.username} - - {user.userType} - -
- {user.createTime && ( -
- - 创建于:{new Date(user.createTime).toLocaleString('zh-CN')} - -
- )} -
+
+
+ {users.map((user) => ( +
setSelectedUserId(user.id)} + onDoubleClick={() => handleDoubleClick(user)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + setSelectedUserId(user.id) + } + }} + > +
+ {user.username} + + {user.userType} +
- ))} -
+ {user.createTime && ( +
+ 创建于:{new Date(user.createTime).toLocaleString('zh-CN')} +
+ )} +
+ ))}
+
-
+
+
-
- -
双击用户可直接选择
+
双击用户可直接选择
- - - + ) } diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx index 68d827d..39462e3 100644 --- a/src/renderer/src/components/ui/Modal.tsx +++ b/src/renderer/src/components/ui/Modal.tsx @@ -20,6 +20,14 @@ interface ModalProps { triggerRef?: React.RefObject /** ID of the title element (for aria-labelledby) */ titleId?: string + /** Selector for the element to focus initially inside the modal */ + initialFocusSelector?: string + /** ID of the element that describes the modal (for aria-describedby) */ + ariaDescribedBy?: string + /** Whether this is an alert dialog (role="alertdialog" instead of "dialog") */ + isAlertDialog?: boolean + /** Whether to disable escape key handling (e.g., during execution) */ + disableEscapeKey?: boolean } const sizeStyles: Record = { @@ -39,7 +47,11 @@ export function Modal({ size = 'md', showCloseButton = true, triggerRef, - titleId + titleId, + initialFocusSelector, + ariaDescribedBy, + isAlertDialog = false, + disableEscapeKey = false }: ModalProps): React.JSX.Element | null { const dialogRef = useRef(null) const [generatedId] = useState( @@ -56,18 +68,27 @@ export function Modal({ isOpen, dialogRef, onClose, - triggerRef: triggerRef || undefined + triggerRef, + initialFocusSelector, + shouldCloseOnEscape: !disableEscapeKey }) if (!isOpen) return null return ( -
+
{/* Backdrop */}